mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-23 23:18:05 +02:00
feat: implement podcast discovery with PodcastIndex API integration and Discover page
This commit is contained in:
1 parent
d7f158a498
commit
9bb61cc8a7
10 files changed
+1254
-5
No files matched your search
+16
-1
@@ -4,6 +4,8 @@ import './styles/globals.css';
|
||||
import { authService } from './services/authService';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Discover from './pages/Discover';
|
||||
import Navigation from './components/Navigation';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -22,7 +24,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<div className="flex">
|
||||
<Navigation />
|
||||
<div className="flex-1 lg:ml-64">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
@@ -39,6 +46,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/discover"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Discover />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Home, Compass } from 'lucide-react';
|
||||
|
||||
export default function Navigation() {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<nav className="fixed left-0 top-0 h-screen w-64 bg-white border-r border-light-200 pt-6 overflow-y-auto hidden lg:flex flex-col">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="px-6 mb-8 flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-primary flex items-center justify-center text-white font-bold">
|
||||
🎙️
|
||||
</div>
|
||||
<span className="text-xl font-bold text-light-900">Podcastic</span>
|
||||
</Link>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="flex-1 px-4">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive('/dashboard')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-light-700 hover:bg-light-100'
|
||||
}`}
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="font-medium">Dashboard</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/discover"
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive('/discover')
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'text-light-700 hover:bg-light-100'
|
||||
}`}
|
||||
>
|
||||
<Compass className="w-5 h-5" />
|
||||
<span className="font-medium">Discover</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-light-200 text-xs text-light-500">
|
||||
<p>Podcastic © 2026</p>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { discoveryService, DiscoveryPodcast } from '../services/discoveryService';
|
||||
import { podcastService } from '../services/podcastService';
|
||||
import Header from '../components/Header';
|
||||
import { Search, Plus, Loader } from 'lucide-react';
|
||||
import { authService } from '../services/authService';
|
||||
|
||||
export default function Discover() {
|
||||
const queryClient = useQueryClient();
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTab, setSelectedTab] = useState<'search' | 'trending'>('trending');
|
||||
const [subscribingId, setSubscribingId] = useState<string | null>(null);
|
||||
|
||||
// Load user
|
||||
useState(() => {
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
const response = await authService.getMe();
|
||||
setUser(response.user);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error);
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
// Search podcasts
|
||||
const { data: searchResults, isLoading: isSearching } = useQuery({
|
||||
queryKey: ['discover', 'search', searchQuery],
|
||||
queryFn: () => discoveryService.discoverPodcasts(searchQuery, 30),
|
||||
enabled: selectedTab === 'search' && searchQuery.length > 2,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Trending podcasts
|
||||
const { data: trendingData, isLoading: isTrendingLoading } = useQuery({
|
||||
queryKey: ['discover', 'trending'],
|
||||
queryFn: () => discoveryService.getTrendingPodcasts(30),
|
||||
enabled: selectedTab === 'trending',
|
||||
staleTime: 30 * 60 * 1000, // Cache for 30 minutes
|
||||
});
|
||||
|
||||
// Subscribe mutation
|
||||
const subscribeMutation = useMutation({
|
||||
mutationFn: (podcast: DiscoveryPodcast) =>
|
||||
discoveryService.subscribeFromDiscovery(podcast.rssUrl, {
|
||||
title: podcast.title,
|
||||
author: podcast.author,
|
||||
imageUrl: podcast.imageUrl,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['podcasts', 'subscriptions'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubscribe = async (podcast: DiscoveryPodcast) => {
|
||||
try {
|
||||
setSubscribingId(podcast.id);
|
||||
await subscribeMutation.mutateAsync(podcast);
|
||||
// Show success toast (you might want to add a toast library)
|
||||
alert(`Added "${podcast.title}" to your subscriptions!`);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.message || 'Failed to subscribe';
|
||||
alert(`Error: ${errorMessage}`);
|
||||
} finally {
|
||||
setSubscribingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
authService.clearTokens();
|
||||
window.location.href = '/login';
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const displayData = selectedTab === 'search' ? searchResults?.podcasts : trendingData?.podcasts;
|
||||
const isLoading = selectedTab === 'search' ? isSearching : isTrendingLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-light-50">
|
||||
<Header
|
||||
title="Discover"
|
||||
subtitle="Find and subscribe to podcasts"
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-12">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-3.5 w-5 h-5 text-light-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search podcasts, authors, topics..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3 rounded-xl border border-light-200 bg-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors text-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-8">
|
||||
<button
|
||||
onClick={() => setSelectedTab('trending')}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
selectedTab === 'trending'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-light-200 text-light-700 hover:bg-light-300'
|
||||
}`}
|
||||
>
|
||||
🔥 Trending
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedTab('search')}
|
||||
disabled={searchQuery.length < 2}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
selectedTab === 'search'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-light-200 text-light-700 hover:bg-light-300'
|
||||
}`}
|
||||
>
|
||||
🔍 Search Results
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<Loader className="w-12 h-12 text-blue-500 animate-spin mx-auto mb-3" />
|
||||
<p className="text-light-600">Loading podcasts...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : displayData && displayData.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayData.map((podcast) => (
|
||||
<div key={podcast.id} className="card hover:shadow-lg transition-shadow">
|
||||
{/* Podcast Image */}
|
||||
{podcast.imageUrl && (
|
||||
<div className="mb-4 rounded-lg overflow-hidden aspect-video bg-light-200">
|
||||
<img
|
||||
src={podcast.imageUrl}
|
||||
alt={podcast.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-light-900 mb-1 line-clamp-2">
|
||||
{podcast.title}
|
||||
</h3>
|
||||
|
||||
{podcast.author && (
|
||||
<p className="text-sm text-light-600 mb-2">{podcast.author}</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-light-500 line-clamp-3 mb-4">
|
||||
{podcast.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 text-xs text-light-500">
|
||||
{podcast.episodeCount && (
|
||||
<span>{podcast.episodeCount} episodes</span>
|
||||
)}
|
||||
{podcast.language && <span>{podcast.language}</span>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSubscribe(podcast)}
|
||||
disabled={subscribingId === podcast.id || subscribeMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{subscribingId === podcast.id ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
Adding...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-light-600 text-lg mb-2">
|
||||
{selectedTab === 'search' && searchQuery.length < 2
|
||||
? 'Type at least 2 characters to search'
|
||||
: 'No podcasts found'}
|
||||
</p>
|
||||
{selectedTab === 'search' && (
|
||||
<p className="text-light-500">
|
||||
Try searching for podcast titles, authors, or topics
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Info */}
|
||||
{(searchResults || trendingData) && (
|
||||
<div className="mt-8 text-center text-sm text-light-500">
|
||||
<p>
|
||||
Powered by{' '}
|
||||
<a
|
||||
href="https://podcastindex.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
Podcast Index
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import api from './api';
|
||||
|
||||
export interface DiscoveryPodcast {
|
||||
id: string;
|
||||
title: string;
|
||||
rssUrl: string;
|
||||
description: string;
|
||||
author?: string;
|
||||
imageUrl?: string;
|
||||
language?: string;
|
||||
episodeCount?: number;
|
||||
}
|
||||
|
||||
export interface DiscoveryResponse {
|
||||
source: 'podcastindex' | 'local';
|
||||
podcasts: DiscoveryPodcast[];
|
||||
count: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const discoveryService = {
|
||||
discoverPodcasts: async (
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
offset: number = 0
|
||||
): Promise<DiscoveryResponse> => {
|
||||
const response = await api.get('/podcasts/discover', {
|
||||
params: { q: query, limit, offset },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrendingPodcasts: async (limit: number = 20): Promise<DiscoveryResponse> => {
|
||||
const response = await api.get('/podcasts/trending', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
subscribeFromDiscovery: async (
|
||||
rssUrl: string,
|
||||
metadata?: {
|
||||
title?: string;
|
||||
author?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
): Promise<{ message: string; podcast: any }> => {
|
||||
const response = await api.post('/podcasts/subscribe-discovery', {
|
||||
rssUrl,
|
||||
...metadata,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user