feat: implement React components - dashboard, auth pages, audio player UI

This commit is contained in:
Michael committed 2026-04-16 09:56:17 +02:00
1 parent 8c00a1b0f5
commit 068f4e7ef7
7 files changed
+791 -20

No files matched your search

+281
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}