mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
feat: implement authentication and core API endpoints - auth, podcasts, episodes
This commit is contained in:
1 parent
8df1161023
commit
8c00a1b0f5
13 files changed
+822
-1
No files matched your search
@@ -0,0 +1,121 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { User } from '../models/User';
|
||||||
|
import { generateTokens } from '../utils/jwt';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
username: z.string().min(2, 'Username must be at least 2 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(1, 'Password required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const register = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password, username } = registerSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await User.findOne({ email });
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ message: 'Email already registered' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
const user = new User({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const { accessToken, refreshToken } = generateTokens(user);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'User registered successfully',
|
||||||
|
user: user.toJSON(),
|
||||||
|
tokens: { accessToken, refreshToken },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Validation error',
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Register error:', error);
|
||||||
|
res.status(500).json({ message: 'Registration failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = loginSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Find user
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await user.comparePassword(password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({ message: 'Invalid email or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const { accessToken, refreshToken } = generateTokens(user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Login successful',
|
||||||
|
user: user.toJSON(),
|
||||||
|
tokens: { accessToken, refreshToken },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Validation error',
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Login error:', error);
|
||||||
|
res.status(500).json({ message: 'Login failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ user: user.toJSON() });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GetMe error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch user' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// In a stateless JWT system, logout is client-side (delete token)
|
||||||
|
// Could implement token blacklist with Redis if needed
|
||||||
|
res.json({ message: 'Logout successful' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
res.status(500).json({ message: 'Logout failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Episode } from '../models/Episode';
|
||||||
|
import { UserProgress } from '../models/UserProgress';
|
||||||
|
import { UserSubscription } from '../models/UserSubscription';
|
||||||
|
|
||||||
|
export const getLatestEpisodes = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit = 30, skip = 0 } = req.query;
|
||||||
|
|
||||||
|
// Get user's subscribed podcasts
|
||||||
|
const subscriptions = await UserSubscription.find({
|
||||||
|
userId: req.user.id,
|
||||||
|
}).select('podcastId');
|
||||||
|
|
||||||
|
const podcastIds = subscriptions.map((sub) => sub.podcastId);
|
||||||
|
|
||||||
|
if (podcastIds.length === 0) {
|
||||||
|
return res.json({ episodes: [], count: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest episodes from subscribed podcasts
|
||||||
|
const episodes = await Episode.find({
|
||||||
|
podcastId: { $in: podcastIds },
|
||||||
|
})
|
||||||
|
.populate('podcastId', 'title author imageUrl')
|
||||||
|
.sort({ pubDate: -1 })
|
||||||
|
.limit(Number(limit))
|
||||||
|
.skip(Number(skip));
|
||||||
|
|
||||||
|
res.json({ episodes, count: episodes.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get latest episodes error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch episodes' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPodcastEpisodes = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { podcastId } = req.params;
|
||||||
|
const { limit = 50, skip = 0 } = req.query;
|
||||||
|
|
||||||
|
const episodes = await Episode.find({ podcastId })
|
||||||
|
.sort({ pubDate: -1 })
|
||||||
|
.limit(Number(limit))
|
||||||
|
.skip(Number(skip));
|
||||||
|
|
||||||
|
res.json({ episodes, count: episodes.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get podcast episodes error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch episodes' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEpisode = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { episodeId } = req.params;
|
||||||
|
|
||||||
|
const episode = await Episode.findById(episodeId).populate(
|
||||||
|
'podcastId',
|
||||||
|
'title author description imageUrl'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!episode) {
|
||||||
|
return res.status(404).json({ message: 'Episode not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ episode });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get episode error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch episode' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveProgress = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { episodeId, position, isCompleted } = req.body;
|
||||||
|
|
||||||
|
if (!episodeId || position === undefined) {
|
||||||
|
return res.status(400).json({ message: 'Episode ID and position required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create progress record
|
||||||
|
let progress = await UserProgress.findOne({
|
||||||
|
userId: req.user.id,
|
||||||
|
episodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (progress) {
|
||||||
|
progress.position = position;
|
||||||
|
if (isCompleted !== undefined) {
|
||||||
|
progress.isCompleted = isCompleted;
|
||||||
|
}
|
||||||
|
progress.lastListenedAt = new Date();
|
||||||
|
} else {
|
||||||
|
progress = new UserProgress({
|
||||||
|
userId: req.user.id,
|
||||||
|
episodeId,
|
||||||
|
position,
|
||||||
|
isCompleted: isCompleted || false,
|
||||||
|
lastListenedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Progress saved',
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save progress error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to save progress' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProgress = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { episodeId } = req.params;
|
||||||
|
|
||||||
|
const progress = await UserProgress.findOne({
|
||||||
|
userId: req.user.id,
|
||||||
|
episodeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!progress) {
|
||||||
|
return res.json({ progress: null, message: 'No progress found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ progress });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get progress error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch progress' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { Podcast } from '../models/Podcast';
|
||||||
|
import { UserSubscription } from '../models/UserSubscription';
|
||||||
|
import { Episode } from '../models/Episode';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const subscribeSchema = z.object({
|
||||||
|
rssUrl: z.string().url('Invalid RSS URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUserSubscriptions = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await UserSubscription.find({
|
||||||
|
userId: req.user.id,
|
||||||
|
})
|
||||||
|
.populate('podcastId')
|
||||||
|
.sort({ subscribedAt: -1 });
|
||||||
|
|
||||||
|
const podcasts = subscriptions.map((sub) => sub.podcastId);
|
||||||
|
|
||||||
|
res.json({ podcasts, count: podcasts.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get subscriptions error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch subscriptions' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscribe = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rssUrl } = subscribeSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Check if podcast exists
|
||||||
|
let podcast = await Podcast.findOne({ rssUrl });
|
||||||
|
|
||||||
|
if (!podcast) {
|
||||||
|
// For now, create a basic podcast entry
|
||||||
|
// In production, you'd fetch from PodcastIndex API or parse the RSS feed
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Podcast not found. Please use a valid RSS URL.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already subscribed
|
||||||
|
const existingSubscription = await UserSubscription.findOne({
|
||||||
|
userId: req.user.id,
|
||||||
|
podcastId: podcast._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSubscription) {
|
||||||
|
return res.status(409).json({ message: 'Already subscribed to this podcast' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
const subscription = new UserSubscription({
|
||||||
|
userId: req.user.id,
|
||||||
|
podcastId: podcast._id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await subscription.save();
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: 'Subscribed successfully',
|
||||||
|
podcast: podcast.toJSON(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'Validation error',
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Subscribe error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to subscribe' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsubscribe = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { podcastId } = req.params;
|
||||||
|
|
||||||
|
const subscription = await UserSubscription.findOneAndDelete({
|
||||||
|
userId: req.user.id,
|
||||||
|
podcastId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return res.status(404).json({ message: 'Subscription not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Unsubscribed successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Unsubscribe error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to unsubscribe' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchPodcasts = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { q, limit = 20, skip = 0 } = req.query;
|
||||||
|
|
||||||
|
if (!q) {
|
||||||
|
return res.status(400).json({ message: 'Search query required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const podcasts = await Podcast.find(
|
||||||
|
{ $text: { $search: q as string } },
|
||||||
|
{ score: { $meta: 'textScore' } }
|
||||||
|
)
|
||||||
|
.sort({ score: { $meta: 'textScore' } })
|
||||||
|
.limit(Number(limit))
|
||||||
|
.skip(Number(skip));
|
||||||
|
|
||||||
|
res.json({ podcasts, count: podcasts.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
res.status(500).json({ message: 'Search failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPodcast = async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { podcastId } = req.params;
|
||||||
|
|
||||||
|
const podcast = await Podcast.findById(podcastId);
|
||||||
|
if (!podcast) {
|
||||||
|
return res.status(404).json({ message: 'Podcast not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ podcast });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get podcast error:', error);
|
||||||
|
res.status(500).json({ message: 'Failed to fetch podcast' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,6 +5,9 @@ import { connectDB } from './config/database';
|
|||||||
import { initRedis, closeRedis } from './config/redis';
|
import { initRedis, closeRedis } from './config/redis';
|
||||||
import { authenticate, optional } from './middleware/auth';
|
import { authenticate, optional } from './middleware/auth';
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
import authRoutes from './routes/authRoutes';
|
||||||
|
import podcastRoutes from './routes/podcastRoutes';
|
||||||
|
import episodeRoutes from './routes/episodeRoutes';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
@@ -36,11 +39,15 @@ app.get('/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Routes - TODO: Import and register routes
|
// API Routes
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ status: 'ok', message: 'API is running' });
|
res.json({ status: 'ok', message: 'API is running' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/podcasts', podcastRoutes);
|
||||||
|
app.use('/api/episodes', episodeRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { register, login, getMe, logout } from '../controllers/authController';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.post('/register', register);
|
||||||
|
router.post('/login', login);
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
router.get('/me', authenticate, getMe);
|
||||||
|
router.post('/logout', authenticate, logout);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getLatestEpisodes,
|
||||||
|
getPodcastEpisodes,
|
||||||
|
getEpisode,
|
||||||
|
saveProgress,
|
||||||
|
getProgress,
|
||||||
|
} from '../controllers/episodeController';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
router.get('/latest', authenticate, getLatestEpisodes);
|
||||||
|
router.post('/progress', authenticate, saveProgress);
|
||||||
|
router.get('/progress/:episodeId', authenticate, getProgress);
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.get('/:episodeId', getEpisode);
|
||||||
|
router.get('/podcast/:podcastId', getPodcastEpisodes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getUserSubscriptions,
|
||||||
|
subscribe,
|
||||||
|
unsubscribe,
|
||||||
|
searchPodcasts,
|
||||||
|
getPodcast,
|
||||||
|
} from '../controllers/podcastController';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
router.get('/subscriptions', authenticate, getUserSubscriptions);
|
||||||
|
router.post('/subscribe', authenticate, subscribe);
|
||||||
|
router.delete('/:podcastId/unsubscribe', authenticate, unsubscribe);
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.get('/search', searchPodcasts);
|
||||||
|
router.get('/:podcastId', getPodcast);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo 🎙️ Podcastic Development Server Launcher
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Check if .env exists
|
||||||
|
if not exist ".env" (
|
||||||
|
echo ⚠️ .env file not found!
|
||||||
|
echo Creating .env from .env.example...
|
||||||
|
copy .env.example .env
|
||||||
|
echo ✓ .env created. Please update with your configuration.
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
REM Check for MongoDB and Redis
|
||||||
|
echo 📡 Checking prerequisites...
|
||||||
|
echo ⚠️ Make sure MongoDB and Redis are running!
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM Start backend
|
||||||
|
echo 🚀 Starting Backend (Express.js)...
|
||||||
|
cd backend
|
||||||
|
start "Podcastic Backend" npm run dev
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
timeout /t 2 /nobreak
|
||||||
|
|
||||||
|
REM Start frontend
|
||||||
|
echo 🚀 Starting Frontend (Vite)...
|
||||||
|
cd frontend
|
||||||
|
start "Podcastic Frontend" npm run dev
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ✓ Servers started!
|
||||||
|
echo.
|
||||||
|
echo 📍 Frontend: http://localhost:3000
|
||||||
|
echo 📍 Backend: http://localhost:5000
|
||||||
|
echo.
|
||||||
|
echo Windows command windows will stay open. Close them to stop servers.
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Podcastic Development Helper Script
|
||||||
|
|
||||||
|
echo "🎙️ Podcastic Development Server Launcher"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f ".env" ]; then
|
||||||
|
echo "⚠️ .env file not found!"
|
||||||
|
echo "Creating .env from .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
echo "✓ .env created. Please update with your configuration."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for MongoDB connection
|
||||||
|
echo "📡 Checking MongoDB connection..."
|
||||||
|
MONGODB_URI=${MONGODB_URI:-"mongodb://localhost:27017/podcastic"}
|
||||||
|
|
||||||
|
if [ "$MONGODB_URI" = "mongodb://localhost:27017/podcastic" ]; then
|
||||||
|
echo "⚠️ Using local MongoDB at $MONGODB_URI"
|
||||||
|
echo " Make sure MongoDB is running!"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for Redis connection
|
||||||
|
echo "🔴 Checking Redis connection..."
|
||||||
|
REDIS_URL=${REDIS_URL:-"redis://localhost:6379"}
|
||||||
|
|
||||||
|
if [ "$REDIS_URL" = "redis://localhost:6379" ]; then
|
||||||
|
echo "⚠️ Using local Redis at $REDIS_URL"
|
||||||
|
echo " Make sure Redis is running!"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start both servers
|
||||||
|
echo "🚀 Starting both servers..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start backend in background
|
||||||
|
echo "→ Backend starting on port 5000..."
|
||||||
|
cd backend
|
||||||
|
npm run dev &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Start frontend in background
|
||||||
|
echo "→ Frontend starting on port 3000..."
|
||||||
|
cd frontend
|
||||||
|
npm run dev &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✓ Servers started!"
|
||||||
|
echo ""
|
||||||
|
echo "📍 Frontend: http://localhost:3000"
|
||||||
|
echo "📍 Backend: http://localhost:5000"
|
||||||
|
echo ""
|
||||||
|
echo "Press Ctrl+C to stop all servers..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for background processes
|
||||||
|
wait $BACKEND_PID
|
||||||
|
wait $FRONTEND_PID
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add auth token to requests
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle token refresh on 401
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Token expired - could implement refresh token logic here
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
_id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
avatar?: string;
|
||||||
|
preferences: {
|
||||||
|
defaultPlaybackSpeed: number;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
language: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
message: string;
|
||||||
|
user: User;
|
||||||
|
tokens: {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
register: async (email: string, password: string, username: string): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post('/auth/register', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
login: async (email: string, password: string): Promise<AuthResponse> => {
|
||||||
|
const response = await api.post('/auth/login', {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getMe: async (): Promise<{ user: User }> => {
|
||||||
|
const response = await api.get('/auth/me');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<{ message: string }> => {
|
||||||
|
const response = await api.post('/auth/logout');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTokens: (accessToken: string, refreshToken: string) => {
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
localStorage.setItem('refreshToken', refreshToken);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTokens: () => ({
|
||||||
|
accessToken: localStorage.getItem('accessToken'),
|
||||||
|
refreshToken: localStorage.getItem('refreshToken'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
clearTokens: () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface Episode {
|
||||||
|
_id: string;
|
||||||
|
podcastId: string | { _id: string; title: string; author: string; imageUrl?: string };
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
audioUrl: string;
|
||||||
|
pubDate: string;
|
||||||
|
duration: number;
|
||||||
|
guid: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodeResponse {
|
||||||
|
episodes: Episode[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProgress {
|
||||||
|
_id: string;
|
||||||
|
userId: string;
|
||||||
|
episodeId: string;
|
||||||
|
position: number;
|
||||||
|
isCompleted: boolean;
|
||||||
|
isSaved: boolean;
|
||||||
|
lastListenedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const episodeService = {
|
||||||
|
getLatestEpisodes: async (
|
||||||
|
limit: number = 30,
|
||||||
|
skip: number = 0
|
||||||
|
): Promise<EpisodeResponse> => {
|
||||||
|
const response = await api.get('/episodes/latest', {
|
||||||
|
params: { limit, skip },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPodcastEpisodes: async (
|
||||||
|
podcastId: string,
|
||||||
|
limit: number = 50,
|
||||||
|
skip: number = 0
|
||||||
|
): Promise<EpisodeResponse> => {
|
||||||
|
const response = await api.get(`/episodes/podcast/${podcastId}`, {
|
||||||
|
params: { limit, skip },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getEpisode: async (episodeId: string): Promise<{ episode: Episode }> => {
|
||||||
|
const response = await api.get(`/episodes/${episodeId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveProgress: async (
|
||||||
|
episodeId: string,
|
||||||
|
position: number,
|
||||||
|
isCompleted?: boolean
|
||||||
|
): Promise<{ message: string; progress: UserProgress }> => {
|
||||||
|
const response = await api.post('/episodes/progress', {
|
||||||
|
episodeId,
|
||||||
|
position,
|
||||||
|
isCompleted,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getProgress: async (episodeId: string): Promise<{ progress: UserProgress | null }> => {
|
||||||
|
const response = await api.get(`/episodes/progress/${episodeId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface Podcast {
|
||||||
|
_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
rssUrl: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
author: string;
|
||||||
|
category: string[];
|
||||||
|
language: string;
|
||||||
|
episodeCount: number;
|
||||||
|
lastFetched: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodcastResponse {
|
||||||
|
podcasts: Podcast[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const podcastService = {
|
||||||
|
getUserSubscriptions: async (): Promise<PodcastResponse> => {
|
||||||
|
const response = await api.get('/podcasts/subscriptions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: async (rssUrl: string): Promise<{ message: string; podcast: Podcast }> => {
|
||||||
|
const response = await api.post('/podcasts/subscribe', { rssUrl });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: async (podcastId: string): Promise<{ message: string }> => {
|
||||||
|
const response = await api.delete(`/podcasts/${podcastId}/unsubscribe`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
searchPodcasts: async (
|
||||||
|
query: string,
|
||||||
|
limit: number = 20,
|
||||||
|
skip: number = 0
|
||||||
|
): Promise<PodcastResponse> => {
|
||||||
|
const response = await api.get('/podcasts/search', {
|
||||||
|
params: { q: query, limit, skip },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPodcast: async (podcastId: string): Promise<{ podcast: Podcast }> => {
|
||||||
|
const response = await api.get(`/podcasts/${podcastId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in new issue
Block a user