Files
lidify/frontend/app/podcasts/genre/[genreId]/page.tsx
2025-12-25 18:58:06 -06:00

194 lines
6.9 KiB
TypeScript

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { Mic2, ArrowLeft } from "lucide-react";
import { api } from "@/lib/api";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
interface Podcast {
id: string;
title: string;
author: string;
coverUrl: string;
feedUrl: string;
itunesId?: number;
}
const GENRE_MAP: { [key: string]: { name: string; searchTerm: string } } = {
"1303": { name: "Comedy", searchTerm: "comedy podcast" },
"1324": {
name: "Society & Culture",
searchTerm: "society culture podcast",
},
"1489": { name: "News", searchTerm: "news podcast" },
"1488": { name: "True Crime", searchTerm: "true crime podcast" },
"1321": { name: "Business", searchTerm: "business podcast" },
"1545": { name: "Sports", searchTerm: "sports podcast" },
"1502": { name: "Leisure", searchTerm: "gaming hobbies podcast" },
};
export default function GenrePage() {
const params = useParams();
const router = useRouter();
const genreId = params.genreId as string;
const genre = GENRE_MAP[genreId];
const [podcasts, setPodcasts] = useState<Podcast[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const LIMIT = 20;
const loadMorePodcasts = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
// Call the paginated endpoint
const data = await api.getPodcastsByGenrePaginated(
parseInt(genreId),
LIMIT,
offset
);
if (data.length < LIMIT) {
setHasMore(false);
}
setPodcasts((prev) => [...prev, ...data]);
setOffset((prev) => prev + data.length);
} catch (error) {
console.error("Failed to load podcasts:", error);
setHasMore(false);
} finally {
setLoading(false);
}
}, [genreId, offset, loading, hasMore]);
// Set up intersection observer for infinite scroll
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMorePodcasts();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadMorePodcasts, hasMore, loading]);
// Load initial podcasts
useEffect(() => {
loadMorePodcasts();
}, []); // Only on mount
const handlePodcastClick = (podcast: Podcast) => {
// Navigate to podcast preview page instead of auto-subscribing
router.push(`/podcasts/${podcast.id || podcast.itunesId}`);
};
if (!genre) {
return (
<div className="flex items-center justify-center h-screen">
<p className="text-white">Genre not found</p>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-black to-[#121212] text-white p-6 md:p-8">
{/* Header */}
<div className="mb-8">
<button
onClick={() => router.push("/podcasts")}
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="w-5 h-5" />
Back to Podcasts
</button>
<h1 className="text-4xl md:text-5xl font-bold">{genre.name}</h1>
<p className="text-gray-400 mt-2">
{podcasts.length} podcast{podcasts.length !== 1 ? "s" : ""}
</p>
</div>
{/* Podcast Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
{podcasts.map((podcast) => (
<div
key={podcast.id}
onClick={() => handlePodcastClick(podcast)}
className="bg-gradient-to-br from-[#121212] to-[#121212] hover:from-[#181818] hover:to-[#1a1a1a] transition-all p-4 rounded-lg cursor-pointer group border border-[#1c1c1c]"
>
<div className="w-full aspect-square bg-[#181818] rounded-full mb-3 overflow-hidden">
{podcast.coverUrl ? (
<img
src={podcast.coverUrl}
alt={podcast.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Mic2 className="w-16 h-16 text-gray-700" />
</div>
)}
</div>
<h3 className="font-bold text-white truncate text-sm">
{podcast.title}
</h3>
<p className="text-xs text-gray-400 truncate">
{podcast.author}
</p>
</div>
))}
</div>
{/* Loading indicator */}
{loading && (
<div className="flex justify-center items-center py-8">
<GradientSpinner size="md" />
</div>
)}
{/* Intersection observer target */}
<div ref={loadMoreRef} className="h-20" />
{/* End of results */}
{!hasMore && podcasts.length > 0 && (
<div className="text-center py-8 text-gray-400">
No more podcasts to load
</div>
)}
{/* No results */}
{!loading && podcasts.length === 0 && (
<div className="text-center py-20">
<Mic2 className="w-16 h-16 text-gray-700 mx-auto mb-4" />
<p className="text-gray-400">No podcasts found</p>
</div>
)}
</div>
);
}