diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4e7734..c0b66c5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,25 +1,9 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './styles/globals.css'; - -// Placeholder pages -const Dashboard = () => ( -
-

Dashboard - Coming Soon

-

Latest episodes will appear here

-
-); - -const Login = () => ( -
-
-

Podcastic

-

Sign in to start listening

- - -
-
-); +import { authService } from './services/authService'; +import Login from './pages/Login'; +import Dashboard from './pages/Dashboard'; const queryClient = new QueryClient({ defaultOptions: { @@ -30,14 +14,33 @@ const queryClient = new QueryClient({ }, }); +// Protected Route Component +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { accessToken } = authService.getTokens(); + + if (!accessToken) { + return ; + } + + return <>{children}; +} + export default function App() { return ( } /> - } /> + + + + } + /> } /> + } /> diff --git a/frontend/src/components/AudioPlayer.tsx b/frontend/src/components/AudioPlayer.tsx new file mode 100644 index 0000000..78d598e --- /dev/null +++ b/frontend/src/components/AudioPlayer.tsx @@ -0,0 +1,281 @@ +import { useState, useRef, useEffect } from 'react'; +import { Episode } from '../services/episodeService'; +import { episodeService } from '../services/episodeService'; +import { + Play, + Pause, + Volume2, + SkipBack, + SkipForward, + ChevronDown, +} from 'lucide-react'; + +interface AudioPlayerProps { + episode: Episode | null; + onClose: () => void; + userId?: string; +} + +export default function AudioPlayer({ episode, onClose, userId }: AudioPlayerProps) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(1); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [isSaving, setIsSaving] = useState(false); + + const podcast = typeof episode?.podcastId === 'object' ? episode.podcastId : null; + + // Save progress periodically + useEffect(() => { + if (!episode || !userId) return; + + const saveProgressInterval = setInterval(async () => { + if (isPlaying) { + try { + setIsSaving(true); + await episodeService.saveProgress( + episode._id, + Math.floor(currentTime), + false + ); + } catch (error) { + console.error('Failed to save progress:', error); + } finally { + setIsSaving(false); + } + } + }, 30000); // Save every 30 seconds + + return () => clearInterval(saveProgressInterval); + }, [episode, userId, currentTime, isPlaying]); + + // Load previous progress + useEffect(() => { + if (!episode || !userId) return; + + const loadProgress = async () => { + try { + const response = await episodeService.getProgress(episode._id); + if (response.progress) { + if (audioRef.current) { + audioRef.current.currentTime = response.progress.position; + } + } + } catch (error) { + console.error('Failed to load progress:', error); + } + }; + + loadProgress(); + }, [episode, userId]); + + const handlePlayPause = () => { + if (!audioRef.current) return; + + if (isPlaying) { + audioRef.current.pause(); + } else { + audioRef.current.play(); + } + setIsPlaying(!isPlaying); + }; + + const handleTimeUpdate = () => { + if (audioRef.current) { + setCurrentTime(audioRef.current.currentTime); + } + }; + + const handleLoadedMetadata = () => { + if (audioRef.current) { + setDuration(audioRef.current.duration); + } + }; + + const handleProgressChange = (e: React.ChangeEvent) => { + const newTime = parseFloat(e.target.value); + setCurrentTime(newTime); + if (audioRef.current) { + audioRef.current.currentTime = newTime; + } + }; + + const handleVolumeChange = (e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (audioRef.current) { + audioRef.current.volume = newVolume; + } + }; + + const handleSpeedChange = (speed: number) => { + setPlaybackSpeed(speed); + if (audioRef.current) { + audioRef.current.playbackRate = speed; + } + }; + + const formatTime = (seconds: number) => { + if (!isFinite(seconds)) return '0:00'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (!episode) { + return null; + } + + return ( +
+ {/* Header */} +
+
+

Now Playing

+ +
+
+ + {/* Player Content */} +
+ {/* Artwork */} +
+ {podcast?.imageUrl ? ( + {podcast.title} + ) : ( +
+ 🎙️ +
+ )} +
+ + {/* Info */} +
+

+ {podcast?.author || 'Unknown Podcast'} +

+

{episode.title}

+

{episode.description}

+
+ + {/* Progress Bar */} +
+ +
+ {formatTime(currentTime)} + {formatTime(duration)} +
+
+ + {/* Controls */} +
+ {/* Previous 15s */} + + + {/* Play/Pause */} + + + {/* Next 30s */} + +
+ + {/* Speed & Volume */} +
+ {/* Playback Speed */} +
+

Speed

+
+ {[0.75, 1, 1.25, 1.5, 1.75, 2].map((speed) => ( + + ))} +
+
+ + {/* Volume */} +
+
+ +

Volume

+
+ +
+
+ + {/* Hidden Audio Element */} +
+
+ ); +} diff --git a/frontend/src/components/EpisodeCard.tsx b/frontend/src/components/EpisodeCard.tsx new file mode 100644 index 0000000..f7b4670 --- /dev/null +++ b/frontend/src/components/EpisodeCard.tsx @@ -0,0 +1,64 @@ +import { Episode } from '../services/episodeService'; +import { Play } from 'lucide-react'; + +interface EpisodeCardProps { + episode: Episode; + onPlay: (episode: Episode) => void; +} + +export default function EpisodeCard({ episode, onPlay }: EpisodeCardProps) { + const podcast = typeof episode.podcastId === 'object' ? episode.podcastId : null; + const pubDate = new Date(episode.pubDate).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const durationMinutes = Math.floor(episode.duration / 60); + + return ( +
+
+ {/* Podcast Image */} +
+ {podcast?.imageUrl ? ( + {podcast.title} + ) : ( +
+ 🎙️ +
+ )} + + {/* Play Button Overlay */} + +
+ + {/* Podcast Title */} +

+ {podcast?.author || 'Unknown'} +

+ + {/* Episode Title */} +

+ {episode.title} +

+ + {/* Episode Meta */} +
+ {pubDate} + {durationMinutes} min +
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..aa3cee7 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,59 @@ +import { Link } from 'react-router-dom'; + +interface HeaderProps { + title: string; + subtitle?: string; + user?: { username: string; avatar?: string } | null; + onLogout?: () => void; +} + +export default function Header({ title, subtitle, user, onLogout }: HeaderProps) { + return ( +
+
+
+ {/* Logo & Title */} +
+ +
+ 🎙️ +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ +
+ + {/* User Menu */} + {user && ( +
+
+ {user.avatar ? ( + {user.username} + ) : ( +
+ {user.username.charAt(0).toUpperCase()} +
+ )} + {user.username} +
+ {onLogout && ( + + )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..4288a2a --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,80 @@ +import { useState, useCallback } from 'react'; +import { authService, User } from '../services/authService'; + +export const useAuth = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const register = useCallback(async (email: string, password: string, username: string) => { + setLoading(true); + setError(null); + try { + const response = await authService.register(email, password, username); + setUser(response.user); + authService.setTokens(response.tokens.accessToken, response.tokens.refreshToken); + return response.user; + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Registration failed'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + const login = useCallback(async (email: string, password: string) => { + setLoading(true); + setError(null); + try { + const response = await authService.login(email, password); + setUser(response.user); + authService.setTokens(response.tokens.accessToken, response.tokens.refreshToken); + return response.user; + } catch (err: any) { + const errorMessage = err.response?.data?.message || 'Login failed'; + setError(errorMessage); + throw err; + } finally { + setLoading(false); + } + }, []); + + const logout = useCallback(async () => { + setLoading(true); + try { + await authService.logout(); + setUser(null); + authService.clearTokens(); + } catch (err: any) { + console.error('Logout error:', err); + } finally { + setLoading(false); + } + }, []); + + const getMe = useCallback(async () => { + setLoading(true); + try { + const response = await authService.getMe(); + setUser(response.user); + return response.user; + } catch (err: any) { + setError('Failed to fetch user'); + throw err; + } finally { + setLoading(false); + } + }, []); + + return { + user, + loading, + error, + register, + login, + logout, + getMe, + isAuthenticated: !!user, + }; +}; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..996b902 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { episodeService, Episode } from '../services/episodeService'; +import { authService } from '../services/authService'; +import Header from '../components/Header'; +import EpisodeCard from '../components/EpisodeCard'; +import AudioPlayer from '../components/AudioPlayer'; + +export default function Dashboard() { + const [user, setUser] = useState(null); + const [selectedEpisode, setSelectedEpisode] = useState(null); + const [loading, setLoading] = useState(true); + + // Load user + useEffect(() => { + const loadUser = async () => { + try { + const response = await authService.getMe(); + setUser(response.user); + } catch (error) { + console.error('Failed to load user:', error); + } finally { + setLoading(false); + } + }; + + loadUser(); + }, []); + + // Fetch latest episodes + const { data: episodesData, isLoading: episodesLoading } = useQuery({ + queryKey: ['episodes', 'latest'], + queryFn: () => episodeService.getLatestEpisodes(30), + enabled: !!user, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + const handleLogout = async () => { + try { + await authService.logout(); + authService.clearTokens(); + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + } + }; + + if (loading) { + return ( +
+
+
+

Loading your podcasts...

+
+
+ ); + } + + return ( +
+
+ +
+ {/* Welcome Section */} +
+

+ Welcome back, {user?.username}! 👋 +

+

+ {episodesData?.count || 0} new episodes from your subscriptions +

+
+ + {/* Latest Episodes Grid */} + {episodesLoading ? ( +
+
+
+

Loading episodes...

+
+
+ ) : episodesData?.episodes && episodesData.episodes.length > 0 ? ( +
+

Latest Episodes

+
+ {episodesData.episodes.map((episode) => ( + + ))} +
+
+ ) : ( +
+

No episodes yet

+

+ Subscribe to podcasts to see episodes in your dashboard +

+
+ )} +
+ + {/* Audio Player */} + {selectedEpisode && ( + setSelectedEpisode(null)} + userId={user?._id} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..42e570b --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,164 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authService } from '../services/authService'; + +export default function Login() { + const navigate = useNavigate(); + const [isLogin, setIsLogin] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [formData, setFormData] = useState({ + email: '', + password: '', + username: '', + }); + + const handleChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + if (isLogin) { + const response = await authService.login(formData.email, formData.password); + authService.setTokens(response.tokens.accessToken, response.tokens.refreshToken); + navigate('/dashboard'); + } else { + const response = await authService.register( + formData.email, + formData.password, + formData.username + ); + authService.setTokens(response.tokens.accessToken, response.tokens.refreshToken); + navigate('/dashboard'); + } + } catch (err: any) { + setError(err.response?.data?.message || 'An error occurred'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Header */} +
+
+ 🎙️ +
+

Podcastic

+

Your podcast streaming companion

+
+ + {/* Form Card */} +
+
+ + +
+ +
+ +
+ {!isLogin && ( + + )} + + + + + + {error && ( +
+ {error} +
+ )} + + +
+
+ + {/* Footer */} +

+ {isLogin + ? "Don't have an account? Click Create Account above" + : 'Already have an account? Click Sign In above'} +

+
+ + ); +}