mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-24 07:28:04 +02:00
feat: implement React components - dashboard, auth pages, audio player UI
This commit is contained in:
1 parent
8c00a1b0f5
commit
068f4e7ef7
7 files changed
+791
-20
No files matched your search
+23
-20
@@ -1,25 +1,9 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
import { authService } from './services/authService';
|
||||||
// Placeholder pages
|
import Login from './pages/Login';
|
||||||
const Dashboard = () => (
|
import Dashboard from './pages/Dashboard';
|
||||||
<div className="p-8">
|
|
||||||
<h1 className="text-4xl font-bold mb-4">Dashboard - Coming Soon</h1>
|
|
||||||
<p className="text-light-600">Latest episodes will appear here</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Login = () => (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-light">
|
|
||||||
<div className="card max-w-md w-full">
|
|
||||||
<h1 className="text-3xl font-bold mb-6">Podcastic</h1>
|
|
||||||
<p className="text-light-600 mb-8">Sign in to start listening</p>
|
|
||||||
<button className="btn-primary w-full mb-4">Sign In</button>
|
|
||||||
<button className="btn-secondary w-full">Create Account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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 <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Dashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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<HTMLAudioElement>(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<HTMLInputElement>) => {
|
||||||
|
const newTime = parseFloat(e.target.value);
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-gradient-light z-50 overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 bg-white/80 backdrop-blur-xl border-b border-light-200">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-light-900">Now Playing</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-light-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Player Content */}
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||||
|
{/* Artwork */}
|
||||||
|
<div className="aspect-square max-w-sm mx-auto mb-8 rounded-3xl overflow-hidden shadow-2xl">
|
||||||
|
{podcast?.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={podcast.imageUrl}
|
||||||
|
alt={podcast.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-primary flex items-center justify-center text-white text-9xl">
|
||||||
|
🎙️
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<p className="text-sm font-semibold text-light-500 uppercase mb-2">
|
||||||
|
{podcast?.author || 'Unknown Podcast'}
|
||||||
|
</p>
|
||||||
|
<h1 className="text-4xl font-bold text-light-900 mb-3">{episode.title}</h1>
|
||||||
|
<p className="text-light-600 line-clamp-3">{episode.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-6 card">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 0}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleProgressChange}
|
||||||
|
className="w-full h-2 bg-light-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-light-600 mt-2">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-6 mb-8">
|
||||||
|
{/* Previous 15s */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.max(0, currentTime - 15);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-3 hover:bg-light-100 rounded-full transition-colors"
|
||||||
|
title="Rewind 15s"
|
||||||
|
>
|
||||||
|
<SkipBack className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<button
|
||||||
|
onClick={handlePlayPause}
|
||||||
|
className="w-20 h-20 rounded-full bg-gradient-primary text-white flex items-center justify-center hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="w-8 h-8 ml-1" fill="currentColor" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-8 h-8 ml-1" fill="currentColor" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Next 30s */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = Math.min(duration, currentTime + 30);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-3 hover:bg-light-100 rounded-full transition-colors"
|
||||||
|
title="Skip 30s"
|
||||||
|
>
|
||||||
|
<SkipForward className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speed & Volume */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* Playback Speed */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-sm font-semibold text-light-900 mb-3">Speed</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[0.75, 1, 1.25, 1.5, 1.75, 2].map((speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
onClick={() => handleSpeedChange(speed)}
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
playbackSpeed === speed
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-light-100 text-light-700 hover:bg-light-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<Volume2 className="w-4 h-4 text-light-600" />
|
||||||
|
<h3 className="text-sm font-semibold text-light-900">Volume</h3>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-full h-2 bg-light-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hidden Audio Element */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={episode.audioUrl}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="group cursor-pointer">
|
||||||
|
<div className="relative rounded-2xl overflow-hidden mb-3 glossy">
|
||||||
|
{/* Podcast Image */}
|
||||||
|
<div className="aspect-square bg-light-200 flex items-center justify-center relative overflow-hidden">
|
||||||
|
{podcast?.imageUrl ? (
|
||||||
|
<img
|
||||||
|
src={podcast.imageUrl}
|
||||||
|
alt={podcast.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gradient-primary flex items-center justify-center text-white text-4xl">
|
||||||
|
🎙️
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Play Button Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => onPlay(episode)}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/40 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-white/90 group-hover:bg-white flex items-center justify-center shadow-lg transform group-hover:scale-110 transition-transform">
|
||||||
|
<Play className="w-6 h-6 text-blue-500 ml-1" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Podcast Title */}
|
||||||
|
<p className="text-xs font-semibold text-light-500 uppercase tracking-wider mb-1">
|
||||||
|
{podcast?.author || 'Unknown'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Episode Title */}
|
||||||
|
<h3 className="text-sm font-bold text-light-900 line-clamp-2 mb-2 group-hover:text-blue-500 transition-colors">
|
||||||
|
{episode.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Episode Meta */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-light-500">
|
||||||
|
<span>{pubDate}</span>
|
||||||
|
<span>{durationMinutes} min</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<header className="sticky top-0 z-50 bg-white/80 backdrop-blur-xl border-b border-light-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Logo & Title */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<Link to="/" className="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>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-light-900">{title}</h1>
|
||||||
|
{subtitle && <p className="text-sm text-light-500">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.avatar ? (
|
||||||
|
<img
|
||||||
|
src={user.avatar}
|
||||||
|
alt={user.username}
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-light-300 flex items-center justify-center text-sm font-semibold text-light-700">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-medium text-light-900">{user.username}</span>
|
||||||
|
</div>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="px-4 py-2 text-sm text-light-600 hover:text-light-900 transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { authService, User } from '../services/authService';
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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<any>(null);
|
||||||
|
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-light">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full border-4 border-light-200 border-t-blue-500 animate-spin mx-auto mb-4"></div>
|
||||||
|
<p className="text-light-600">Loading your podcasts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-light-50">
|
||||||
|
<Header
|
||||||
|
title="Podcastic"
|
||||||
|
subtitle="Your podcast companion"
|
||||||
|
user={user}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
{/* Welcome Section */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-light-900 mb-2">
|
||||||
|
Welcome back, {user?.username}! 👋
|
||||||
|
</h2>
|
||||||
|
<p className="text-light-600">
|
||||||
|
{episodesData?.count || 0} new episodes from your subscriptions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latest Episodes Grid */}
|
||||||
|
{episodesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full border-4 border-light-200 border-t-blue-500 animate-spin mx-auto mb-3"></div>
|
||||||
|
<p className="text-light-600">Loading episodes...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : episodesData?.episodes && episodesData.episodes.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-light-900 mb-6">Latest Episodes</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{episodesData.episodes.map((episode) => (
|
||||||
|
<EpisodeCard
|
||||||
|
key={episode._id}
|
||||||
|
episode={episode}
|
||||||
|
onPlay={setSelectedEpisode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-light-600 text-lg mb-4">No episodes yet</p>
|
||||||
|
<p className="text-light-500">
|
||||||
|
Subscribe to podcasts to see episodes in your dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Audio Player */}
|
||||||
|
{selectedEpisode && (
|
||||||
|
<AudioPlayer
|
||||||
|
episode={selectedEpisode}
|
||||||
|
onClose={() => setSelectedEpisode(null)}
|
||||||
|
userId={user?._id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
username: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-gradient-light flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-primary flex items-center justify-center text-white text-4xl mx-auto mb-4">
|
||||||
|
🎙️
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-light-900 mb-2">Podcastic</h1>
|
||||||
|
<p className="text-light-600">Your podcast streaming companion</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<div className="card mb-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsLogin(!isLogin);
|
||||||
|
setError(null);
|
||||||
|
setFormData({ email: '', password: '', username: '' });
|
||||||
|
}}
|
||||||
|
className="w-full mb-4"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
isLogin
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-light-100 text-light-700 hover:bg-light-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsLogin(true)}
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
!isLogin
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-light-100 text-light-700 hover:bg-light-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => setIsLogin(false)}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{!isLogin && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
placeholder="Username"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={!isLogin}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-primary w-full"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div className="spinner border-white"></div>
|
||||||
|
{isLogin ? 'Signing in...' : 'Creating account...'}
|
||||||
|
</div>
|
||||||
|
) : isLogin ? (
|
||||||
|
'Sign In'
|
||||||
|
) : (
|
||||||
|
'Create Account'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<p className="text-center text-sm text-light-600">
|
||||||
|
{isLogin
|
||||||
|
? "Don't have an account? Click Create Account above"
|
||||||
|
: 'Already have an account? Click Sign In above'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user