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

608 lines
30 KiB
TypeScript

"use client";
import { useEffect, useState, useRef, useMemo } from "react";
import { api } from "@/lib/api";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { Loader2, Mic2, Search, Plus } from "lucide-react";
import { useToast } from "@/lib/toast-context";
import { GradientSpinner } from "@/components/ui/GradientSpinner";
import { usePodcastsQuery, useTopPodcastsQuery } from "@/hooks/useQueries";
import Image from "next/image";
// Always proxy images through the backend for caching and mobile compatibility
const getProxiedImageUrl = (imageUrl: string | undefined): string | null => {
if (!imageUrl) return null;
return api.getCoverArtUrl(imageUrl, 300);
};
interface Podcast {
id: string;
title: string;
author: string;
description?: string;
coverUrl: string;
autoDownloadEpisodes: boolean;
genres?: string[];
feedUrl?: string;
episodes?: any[];
episodeCount?: number;
}
interface SearchResult {
type?: string;
id: number;
name?: string;
artist?: string;
title?: string;
author?: string;
coverUrl: string;
feedUrl: string;
trackCount?: number;
itunesId?: number;
}
export default function PodcastsPage() {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isAuthenticated } = useAuth();
const router = useRouter();
const { toast } = useToast();
// Use React Query hooks
const { data: podcasts = [], isLoading: isLoadingPodcasts } =
usePodcastsQuery();
const { data: topPodcasts = [], isLoading: isLoadingTopPodcasts } =
useTopPodcastsQuery(12);
// Load discovery data manually (complex multi-genre fetch)
const [relatedPodcasts, setRelatedPodcasts] = useState<{
[key: string]: SearchResult[];
}>({});
// Sorting and pagination state for "My Podcasts"
type SortOption = 'title' | 'author' | 'recent';
const [sortBy, setSortBy] = useState<SortOption>('title');
const [itemsPerPage, setItemsPerPage] = useState<number>(50);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
if (isAuthenticated) {
loadDiscovery();
}
}, [isAuthenticated]);
const isLoading = isLoadingPodcasts || isLoadingTopPodcasts;
// Sort and paginate "My Podcasts"
const sortedPodcasts = useMemo(() => {
const sorted = [...podcasts];
switch (sortBy) {
case 'title':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'author':
sorted.sort((a, b) => a.author.localeCompare(b.author));
break;
case 'recent':
// Sort by episode count (most episodes = most likely actively listened)
sorted.sort((a, b) => (b.episodeCount || 0) - (a.episodeCount || 0));
break;
}
return sorted;
}, [podcasts, sortBy]);
const totalPages = Math.ceil(sortedPodcasts.length / itemsPerPage);
const paginatedPodcasts = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedPodcasts.slice(start, start + itemsPerPage);
}, [sortedPodcasts, currentPage, itemsPerPage]);
// Reset page when sort changes
useEffect(() => {
setCurrentPage(1);
}, [sortBy]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setShowDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Debounced search
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (searchQuery.trim().length < 2) {
setSearchResults([]);
setShowDropdown(false);
return;
}
setIsSearching(true);
searchTimeoutRef.current = setTimeout(async () => {
try {
// Use discover endpoint to search iTunes for NEW podcasts
const results = await api.discoverSearch(
searchQuery,
"podcasts",
8
);
// Filter for podcasts from the results array
const podcastResults =
results?.results?.filter(
(r: any) => r.type === "podcast"
) || [];
setSearchResults(podcastResults);
setShowDropdown(podcastResults.length > 0);
} catch (error) {
console.error("Podcast search failed:", error);
setSearchResults([]);
setShowDropdown(false);
} finally {
setIsSearching(false);
}
}, 500);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery]);
const loadDiscovery = async () => {
try {
// Load popular genres
// iTunes genre IDs: Comedy=1303, Society&Culture=1324, News=1489,
// True Crime=1488, Business=1321, Sports=1545, Leisure=1502
const genreIds = [
1303, // Comedy
1324, // Society & Culture
1489, // News
1488, // True Crime
1321, // Business
1545, // Sports
1502, // Leisure (Gaming & Hobbies)
];
const genreData = await api.getPodcastsByGenre(genreIds);
setRelatedPodcasts(genreData);
} catch (error) {
console.error("Failed to load podcast discovery:", error);
}
};
const handleSubscribe = async (result: SearchResult | any) => {
try {
toast.info(`Subscribing to ${result.name || result.title}...`);
// For top/genre podcasts from RSS, we have itunesId but no feedUrl
// Pass itunesId and let backend look up the feedUrl
const itunesId =
result.itunesId?.toString() || result.id?.toString();
const response = await api.subscribePodcast(
result.feedUrl || "",
itunesId
);
if (response.success && response.podcast?.id) {
toast.success(`Subscribed to ${result.name || result.title}!`);
setSearchQuery("");
setShowDropdown(false);
// Navigate to new podcast (React Query will automatically refetch)
router.push(`/podcasts/${response.podcast.id}`);
}
} catch (error: any) {
console.error("Subscribe error:", error);
toast.error(error.message || "Failed to subscribe");
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-black">
<GradientSpinner size="md" />
</div>
);
}
return (
<div className="min-h-screen relative">
{/* Quick gradient fade - yellow to purple */}
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute inset-0 bg-gradient-to-b from-[#ecb200]/15 via-purple-900/10 to-transparent"
style={{ height: "35vh" }}
/>
<div
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-[#ecb200]/8 via-transparent to-transparent"
style={{ height: "25vh" }}
/>
</div>
{/* Hero Section */}
<div className="relative">
<div className="px-4 md:px-8 py-6">
<h1 className="text-2xl font-bold text-white mb-4">
Podcasts
</h1>
{/* Quick Search - Full Width on Mobile */}
<div
className="relative w-full md:w-96 md:ml-auto"
ref={dropdownRef}
>
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500 z-10" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Quick add..."
className="w-full pl-11 pr-4 py-3 bg-white/5 border border-white/10 rounded-full text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 focus:bg-white/10 transition-all text-sm"
/>
{isSearching && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-10">
<GradientSpinner size="sm" />
</div>
)}
{/* Dropdown Results */}
{showDropdown && searchResults.length > 0 && (
<div className="absolute top-full left-0 mt-2 w-full bg-[#121212] border border-white/10 rounded-lg shadow-2xl overflow-hidden z-50 max-h-96 overflow-y-auto">
{searchResults.map((result) => {
const imageUrl = getProxiedImageUrl(result.coverUrl);
return (
<div
key={result.id}
className="flex items-center gap-3 p-3 hover:bg-white/5 transition-colors cursor-pointer border-b border-white/5 last:border-b-0"
onClick={() => {
router.push(
`/podcasts/${result.id}`
);
setShowDropdown(false);
}}
>
{/* Cover Art */}
<div className="w-12 h-12 rounded-full bg-[#181818] flex-shrink-0 overflow-hidden relative">
{imageUrl ? (
<Image
src={imageUrl}
alt={result.name || "Podcast"}
fill
sizes="48px"
className="object-cover"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Mic2 className="w-6 h-6 text-gray-600" />
</div>
)}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-white font-semibold text-sm truncate">
{result.name}
</h3>
<p className="text-gray-400 text-xs truncate">
{result.artist}
</p>
</div>
{/* Add Button */}
<div className="flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-purple-500 hover:bg-purple-400 flex items-center justify-center transition-colors">
<Plus className="w-4 h-4 text-white" />
</div>
</div>
</div>
);
})}
</div>
)}
{/* No Results */}
{showDropdown &&
searchResults.length === 0 &&
!isSearching &&
searchQuery.length >= 2 && (
<div className="absolute top-full left-0 mt-2 w-full bg-[#121212] border border-white/10 rounded-lg shadow-2xl p-4 z-50">
<p className="text-gray-400 text-sm text-center">
No podcasts found for "{searchQuery}"
</p>
</div>
)}
</div>
</div>
</div>
<div className="relative px-4 md:px-8 pb-24 space-y-12">
{/* My Podcasts */}
{podcasts.length > 0 && (
<section>
<div className="flex flex-wrap items-center justify-between gap-4 mb-6">
<h2 className="text-xl font-bold text-white">
My Podcasts
</h2>
<div className="flex flex-wrap items-center gap-2">
{/* Sort Dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="px-4 py-2 bg-[#1a1a1a] border border-white/10 rounded-full text-white text-sm focus:outline-none focus:border-purple-500 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value="title">Title (A-Z)</option>
<option value="author">Author (A-Z)</option>
<option value="recent">Most Episodes</option>
</select>
{/* Items per page */}
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="px-4 py-2 bg-[#1a1a1a] border border-white/10 rounded-full text-white text-sm focus:outline-none focus:border-purple-500 [&>option]:bg-[#1a1a1a] [&>option]:text-white"
>
<option value={25}>25 per page</option>
<option value={50}>50 per page</option>
<option value={100}>100 per page</option>
<option value={250}>250 per page</option>
</select>
<span className="text-sm text-gray-400">
{podcasts.length} {podcasts.length === 1 ? 'podcast' : 'podcasts'}
</span>
</div>
</div>
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5 3xl:grid-cols-5 gap-4"
data-tv-section="my-podcasts"
>
{paginatedPodcasts.map((podcast, index) => {
const imageUrl = getProxiedImageUrl(podcast.coverUrl);
return (
<div
key={podcast.id}
onClick={() =>
router.push(`/podcasts/${podcast.id}`)
}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className="bg-transparent hover:bg-white/5 transition-all p-3 rounded-md cursor-pointer group"
>
<div className="w-full aspect-square bg-[#282828] rounded-full mb-2.5 overflow-hidden relative shadow-lg">
{imageUrl ? (
<Image
src={imageUrl}
alt={podcast.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
className="object-cover group-hover:scale-105 transition-transform"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Mic2 className="w-16 h-16 text-gray-700" />
</div>
)}
</div>
<h3 className="text-sm font-semibold text-white truncate mb-0.5">
{podcast.title}
</h3>
<p className="text-xs text-gray-400 truncate">
{podcast.author}
</p>
</div>
);
})}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8 pt-4 border-t border-white/10">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-2 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
First
</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-2 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Prev
</button>
<span className="px-4 py-2 text-sm text-white">
Page {currentPage} of {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-2 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
Last
</button>
</div>
)}
</section>
)}
{/* Top Podcasts */}
{topPodcasts.length > 0 && (
<section>
<h2 className="text-xl font-bold text-white mb-6">
Top Podcasts
</h2>
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5 3xl:grid-cols-5 gap-4"
data-tv-section="top-podcasts"
>
{topPodcasts.map((podcast, index) => {
const imageUrl = getProxiedImageUrl(podcast.coverUrl);
return (
<div
key={podcast.id}
onClick={() =>
router.push(`/podcasts/${podcast.id}`)
}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className="bg-transparent hover:bg-white/5 transition-all p-3 rounded-md cursor-pointer group"
>
<div className="w-full aspect-square bg-[#282828] rounded-full mb-2.5 overflow-hidden relative shadow-lg">
{imageUrl ? (
<Image
src={imageUrl}
alt={podcast.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
className="object-cover group-hover:scale-105 transition-transform"
unoptimized
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Mic2 className="w-16 h-16 text-gray-700" />
</div>
)}
</div>
<h3 className="text-sm font-semibold text-white truncate mb-0.5">
{podcast.title}
</h3>
<p className="text-xs text-gray-400 truncate">
{podcast.author}
</p>
</div>
);
})}
</div>
</section>
)}
{/* Genre-based Discovery - Ordered by popularity */}
{[
{ id: "1303", name: "Comedy" },
{ id: "1324", name: "Society & Culture" },
{ id: "1489", name: "News" },
{ id: "1488", name: "True Crime" },
{ id: "1321", name: "Business" },
{ id: "1545", name: "Sports" },
{ id: "1502", name: "Leisure" },
].map(({ id: genreId, name: genreName }) => {
const genrePodcasts = relatedPodcasts[genreId] || [];
return genrePodcasts.length > 0 ? (
<section key={genreId}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-white">
{genreName}
</h2>
<button
onClick={() =>
router.push(
`/podcasts/genre/${genreId}`
)
}
className="text-sm font-semibold text-gray-400 hover:text-white transition-colors"
>
View More
</button>
</div>
<div
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-5 2xl:grid-cols-5 3xl:grid-cols-5 gap-4"
data-tv-section={`genre-${genreId}`}
>
{genrePodcasts.map((podcast, index) => {
const imageUrl = getProxiedImageUrl(podcast.coverUrl);
return (
<div
key={podcast.id}
onClick={() =>
router.push(
`/podcasts/${podcast.id}`
)
}
data-tv-card
data-tv-card-index={index}
tabIndex={0}
className="bg-transparent hover:bg-white/5 transition-all p-3 rounded-md cursor-pointer group"
>
<div className="w-full aspect-square bg-[#282828] rounded-full mb-2.5 overflow-hidden relative shadow-lg">
{imageUrl ? (
<Image
src={imageUrl}
alt={podcast.title}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
className="object-cover group-hover:scale-105 transition-transform"
unoptimized
/>
) : (
<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>
</section>
) : null;
})}
{/* Empty State */}
{podcasts.length === 0 && topPodcasts.length === 0 && (
<div className="flex flex-col items-center justify-center py-24">
<Mic2 className="w-24 h-24 text-gray-700 mb-6" />
<h2 className="text-2xl font-bold text-white mb-2">
Discover Podcasts
</h2>
<p className="text-gray-400 text-center max-w-md">
Search for podcasts above to subscribe and start
listening
</p>
</div>
)}
</div>
</div>
);
}