feat: initialize Podcastic project foundation - setup backend and frontend

This commit is contained in:
Michael committed 2026-04-16 09:53:36 +02:00
commit 8df1161023
31 files changed
+1626

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(rtk git:*)",
"Bash(rtk npm:*)"
]
}
}
+24
View File
@@ -0,0 +1,24 @@
# Backend Configuration
NODE_ENV=development
PORT=5000
API_URL=http://localhost:5000
# MongoDB Configuration
MONGODB_URI=mongodb://mongo:27017/podcastic
MONGODB_USER=podcastic
MONGODB_PASSWORD=podcastic_dev
# Redis Configuration
REDIS_URL=redis://redis:6379
# JWT Configuration
JWT_SECRET=your_super_secret_jwt_key_change_in_production
JWT_EXPIRES_IN=7d
JWT_REFRESH_EXPIRES_IN=30d
# Podcast API (PodcastIndex)
PODCAST_INDEX_API_KEY=your_podcast_index_api_key
PODCAST_INDEX_API_SECRET=your_podcast_index_secret
# Frontend Configuration
VITE_API_URL=http://localhost:5000/api
+43
View File
@@ -0,0 +1,43 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
pnpm-lock.yaml
# Environment
.env
.env.local
.env.*.local
# Build outputs
dist/
build/
*.tsbuildinfo
# Development
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Testing
coverage/
.nyc_output/
# Docker
.dockerignore
+133
View File
@@ -0,0 +1,133 @@
<!-- rtk-instructions v2 -->
# RTK (Rust Token Killer) - Token-Optimized Commands
## Golden Rule
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
**Important**: Even in command chains with `&&`, use `rtk`:
```bash
# ❌ Wrong
git add . && git commit -m "msg" && git push
# ✅ Correct
rtk git add . && rtk git commit -m "msg" && rtk git push
```
## RTK Commands by Workflow
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
rtk next build # Next.js build with route metrics (87%)
```
### Test (90-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
```
### Git (59-80% savings)
```bash
rtk git status # Compact status
rtk git log # Compact log (works with all git flags)
rtk git diff # Compact diff (80%)
rtk git show # Compact show (80%)
rtk git add # Ultra-compact confirmations (59%)
rtk git commit # Ultra-compact confirmations (59%)
rtk git push # Ultra-compact confirmations
rtk git pull # Ultra-compact confirmations
rtk git branch # Compact branch list
rtk git fetch # Compact fetch
rtk git stash # Compact stash
rtk git worktree # Compact worktree
```
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
rtk pnpm list # Compact dependency tree (70%)
rtk pnpm outdated # Compact outdated packages (80%)
rtk pnpm install # Compact install output (90%)
rtk npm run <script> # Compact npm script output
rtk npx <cmd> # Compact npx command output
rtk prisma # Prisma without ASCII art (88%)
```
### Files & Search (60-75% savings)
```bash
rtk ls <path> # Tree format, compact (65%)
rtk read <file> # Code reading with filtering (60%)
rtk grep <pattern> # Search grouped by file (75%)
rtk find <pattern> # Find grouped by directory (70%)
```
### Analysis & Debug (70-90% savings)
```bash
rtk err <cmd> # Filter errors only from any command
rtk log <file> # Deduplicated logs with counts
rtk json <file> # JSON structure without values
rtk deps # Dependency overview
rtk env # Environment variables compact
rtk summary <cmd> # Smart summary of command output
rtk diff # Ultra-compact diffs
```
### Infrastructure (85% savings)
```bash
rtk docker ps # Compact container list
rtk docker images # Compact image list
rtk docker logs <c> # Deduplicated logs
rtk kubectl get # Compact resource list
rtk kubectl logs # Deduplicated pod logs
```
### Network (65-70% savings)
```bash
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->
+237
View File
@@ -0,0 +1,237 @@
# 🎙️ Podcastic
A modern, ultra-responsive multi-user podcast streaming application with an Apple TV-inspired design.
## ✨ Features
- **Podcast Discovery & Management**: Search, subscribe, and organize your favorite podcasts
- **Stream Playback**: Direct audio streaming without disk usage
- **Dashboard**: View latest episodes from all subscriptions
- **User Accounts**: Multi-user with JWT authentication
- **Responsive Design**: Mobile-first, works perfectly on all devices
- **Ultra-Fast**: Optimized with Redis caching and efficient database queries
- **Docker Ready**: Containerized for easy deployment
## 🛠️ Tech Stack
### Frontend
- React 18 + TypeScript
- Vite (ultra-fast bundler)
- Tailwind CSS (styling with Apple TV aesthetic)
- React Router (navigation)
- TanStack Query (server state management)
### Backend
- Node.js + Express.js
- MongoDB (flexible data storage)
- Redis (caching & performance)
- JWT (authentication)
- TypeScript
### DevOps
- Docker & Docker Compose
- Alpine images for minimal size
## 🚀 Quick Start
### Prerequisites
- Docker & Docker Compose (recommended)
- Node.js 20+ (for local development)
- MongoDB (local or Atlas)
- Redis (local or cloud)
### Using Docker Compose (Recommended)
```bash
# Clone and enter directory
cd Podcastic
# Create .env file from template
cp .env.example .env
# Start all services
docker-compose up -d
# Frontend: http://localhost:3000
# Backend API: http://localhost:5000
# MongoDB: localhost:27017
# Redis: localhost:6379
```
### Local Development
#### Backend
```bash
cd backend
npm install
npm run dev # Starts on port 5000
```
#### Frontend
```bash
cd frontend
npm install
npm run dev # Starts on port 3000
```
## 📁 Project Structure
```
Podcastic/
├── frontend/ # React + Vite application
│ ├── src/
│ │ ├── components/
│ │ ├── pages/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── styles/
│ └── Dockerfile
│
├── backend/ # Express.js API
│ ├── src/
│ │ ├── routes/
│ │ ├── controllers/
│ │ ├── models/
│ │ ├── middleware/
│ │ ├── services/
│ │ └── config/
│ └── Dockerfile
│
├── docker-compose.yml
├── .env.example
└── README.md
```
## 🔑 Key Models
### User
- Email, password, username, avatar
- Preferences (playback speed, theme, language)
### Podcast
- Title, description, RSS URL, image
- Author, categories, language
- Episode count, last fetched timestamp
### Episode
- Title, description, audio URL
- Publication date, duration
- Unique GUID per podcast
### UserProgress
- Current position in episode (seconds)
- Completion status
- Saved episodes
- Listen history
## 📡 API Endpoints (To be implemented)
### Authentication
- `POST /api/auth/register` - User registration
- `POST /api/auth/login` - User login
- `POST /api/auth/refresh` - Refresh access token
### Podcasts
- `GET /api/podcasts` - Get user subscriptions
- `POST /api/podcasts/subscribe` - Add subscription
- `DELETE /api/podcasts/:id/unsubscribe` - Remove subscription
- `GET /api/podcasts/search` - Search podcasts
### Episodes
- `GET /api/episodes/latest` - Latest episodes dashboard
- `GET /api/episodes/:id` - Get episode details
- `GET /api/podcasts/:id/episodes` - Podcast episodes
### Progress
- `POST /api/progress` - Save listening position
- `GET /api/progress/:episodeId` - Get saved position
## 🎨 Design System
### Apple TV Style
- Light theme with subtle gradients
- Glassmorphism effects (backdrop blur)
- Rounded corners (lg/xl)
- Soft shadows for depth
- Generous spacing
### Responsive Breakpoints
- Mobile: < 640px
- Tablet: 640px - 1024px
- Desktop: 1024px+
## 🔒 Security
- JWT tokens for stateless authentication
- Bcrypt password hashing
- CORS configured
- Input validation with Zod
- Environment variables for secrets
## 📊 Performance Targets
- Dashboard load: < 1s (on 4G)
- Stream start: < 2s
- Docker image: < 100MB total
- Startup time: < 5s
## 🐳 Docker Commands
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# View logs
docker-compose logs -f [service_name]
# Rebuild images
docker-compose build --no-cache
# Remove volumes (careful!)
docker-compose down -v
```
## 🧪 Testing
Coming soon...
## 📝 Development Workflow
1. Create feature branch: `git checkout -b feature/my-feature`
2. Make changes
3. Test thoroughly
4. Commit: `git commit -m "feat: add my feature"`
5. Push: `git push origin feature/my-feature`
## 🔄 Environment Variables
See `.env.example` for all available options. Key ones:
- `NODE_ENV` - development/production
- `MONGODB_URI` - MongoDB connection string
- `REDIS_URL` - Redis connection URL
- `JWT_SECRET` - Secret for signing tokens
- `PODCAST_INDEX_API_KEY` - API key for podcast search (optional)
## 📚 Resources
- [React Documentation](https://react.dev)
- [Express.js Guide](https://expressjs.com)
- [MongoDB Docs](https://docs.mongodb.com)
- [Tailwind CSS](https://tailwindcss.com)
- [TypeScript Handbook](https://www.typescriptlang.org/docs)
## 📄 License
MIT
## 👨‍💻 Author
Built with ❤️
---
**Status**: Under development - Phase 1 (Foundation) in progress
+47
View File
@@ -0,0 +1,47 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY tsconfig.json ./
COPY src ./src
# Build TypeScript
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy built app from builder
COPY --from=builder /app/dist ./dist
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:5000/health', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start application
CMD ["node", "dist/index.js"]
+41
View File
@@ -0,0 +1,41 @@
{
"name": "podcastic-backend",
"version": "1.0.0",
"description": "Podcastic - Multi-user podcast streaming application backend",
"main": "dist/index.js",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src",
"test": "vitest"
},
"keywords": ["podcast", "streaming", "audio"],
"author": "",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.5.0",
"redis": "^4.6.11",
"jsonwebtoken": "^9.0.0",
"bcryptjs": "^2.4.3",
"dotenv": "^16.3.1",
"cors": "^2.8.5",
"zod": "^3.22.4",
"rss-parser": "^3.12.0",
"axios": "^1.5.0"
},
"devDependencies": {
"@types/express": "^4.17.20",
"@types/node": "^20.8.0",
"@types/jsonwebtoken": "^9.0.4",
"@types/bcryptjs": "^2.4.2",
"typescript": "^5.2.2",
"tsx": "^4.1.0",
"eslint": "^8.50.0",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"vitest": "^0.34.6"
}
}
+28
View File
@@ -0,0 +1,28 @@
import mongoose from 'mongoose';
export const connectDB = async () => {
try {
const mongoUri = process.env.MONGODB_URI || 'mongodb://localhost:27017/podcastic';
await mongoose.connect(mongoUri, {
retryWrites: true,
w: 'majority',
});
console.log('✓ MongoDB connected successfully');
return mongoose.connection;
} catch (error) {
console.error('✗ MongoDB connection failed:', error);
process.exit(1);
}
};
export const disconnectDB = async () => {
try {
await mongoose.disconnect();
console.log('✓ MongoDB disconnected');
} catch (error) {
console.error('✗ MongoDB disconnection failed:', error);
process.exit(1);
}
};
+46
View File
@@ -0,0 +1,46 @@
import { createClient } from 'redis';
let redisClient: ReturnType<typeof createClient> | null = null;
export const initRedis = async () => {
try {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
redisClient = createClient({
url: redisUrl,
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('✗ Redis max retries reached');
return new Error('Max retries');
}
return retries * 50;
},
},
});
redisClient.on('error', (err) => console.error('Redis error:', err));
redisClient.on('connect', () => console.log('✓ Redis connected'));
redisClient.on('reconnecting', () => console.log('→ Redis reconnecting...'));
await redisClient.connect();
return redisClient;
} catch (error) {
console.error('✗ Redis connection failed:', error);
process.exit(1);
}
};
export const getRedis = () => {
if (!redisClient) {
throw new Error('Redis not initialized');
}
return redisClient;
};
export const closeRedis = async () => {
if (redisClient) {
await redisClient.quit();
console.log('✓ Redis connection closed');
}
};
+80
View File
@@ -0,0 +1,80 @@
import express from 'express';
import cors from 'cors';
import 'dotenv/config';
import { connectDB } from './config/database';
import { initRedis, closeRedis } from './config/redis';
import { authenticate, optional } from './middleware/auth';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT || 5000;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ limit: '10mb', extended: true }));
// Initialize databases on startup
let isInitialized = false;
const initialize = async () => {
if (isInitialized) return;
try {
await connectDB();
await initRedis();
isInitialized = true;
console.log('✓ All services initialized');
} catch (error) {
console.error('✗ Initialization failed:', error);
process.exit(1);
}
};
// Health check endpoint (no auth required)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes - TODO: Import and register routes
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'API is running' });
});
// 404 handler
app.use(notFoundHandler);
// Error handler (must be last)
app.use(errorHandler);
// Graceful shutdown
const gracefulShutdown = async () => {
console.log('\nShutting down gracefully...');
await closeRedis();
process.exit(0);
};
process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown);
// Start server
const startServer = async () => {
try {
await initialize();
app.listen(PORT, () => {
console.log(`\n${'='.repeat(50)}`);
console.log(`🎙️ Podcastic Backend`);
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`${'='.repeat(50)}\n`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
startServer();
export default app;
+54
View File
@@ -0,0 +1,54 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken, JwtPayload } from '../utils/jwt';
// Extend Express Request to include user info
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Missing or invalid authorization header' });
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
try {
const decoded = verifyAccessToken(token);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ message: 'Invalid or expired token' });
}
} catch (error) {
return res.status(500).json({ message: 'Authentication error' });
}
};
export const optional = (req: Request, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
try {
const decoded = verifyAccessToken(token);
req.user = decoded;
} catch (error) {
// Token invalid but optional, continue
}
}
next();
} catch (error) {
next();
}
};
+31
View File
@@ -0,0 +1,31 @@
import { Request, Response, NextFunction } from 'express';
export interface AppError extends Error {
statusCode?: number;
details?: unknown;
}
export const errorHandler = (
error: AppError,
req: Request,
res: Response,
next: NextFunction
) => {
const statusCode = error.statusCode || 500;
const message = error.message || 'Internal Server Error';
console.error(`[${statusCode}] ${message}`, error.details || '');
res.status(statusCode).json({
success: false,
message,
...(process.env.NODE_ENV === 'development' && { details: error.details }),
});
};
export const notFoundHandler = (req: Request, res: Response) => {
res.status(404).json({
success: false,
message: 'Route not found',
});
};
+65
View File
@@ -0,0 +1,65 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IEpisode extends Document {
podcastId: mongoose.Types.ObjectId;
title: string;
description: string;
audioUrl: string;
pubDate: Date;
duration: number; // in seconds
guid: string; // unique identifier from RSS
imageUrl?: string;
createdAt: Date;
updatedAt: Date;
}
const episodeSchema = new Schema<IEpisode>(
{
podcastId: {
type: Schema.Types.ObjectId,
ref: 'Podcast',
required: true,
index: true,
},
title: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
default: '',
},
audioUrl: {
type: String,
required: true,
},
pubDate: {
type: Date,
required: true,
index: true,
},
duration: {
type: Number,
default: 0,
},
guid: {
type: String,
required: true,
},
imageUrl: {
type: String,
default: null,
},
},
{
timestamps: true,
}
);
// Compound index for unique episode per podcast
episodeSchema.index({ podcastId: 1, guid: 1 }, { unique: true });
episodeSchema.index({ pubDate: -1 });
episodeSchema.index({ title: 'text', description: 'text' });
export const Episode = mongoose.model<IEpisode>('Episode', episodeSchema);
+69
View File
@@ -0,0 +1,69 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IPodcast extends Document {
title: string;
description: string;
rssUrl: string;
imageUrl?: string;
author: string;
category: string[];
language: string;
lastFetched: Date;
episodeCount: number;
createdAt: Date;
updatedAt: Date;
}
const podcastSchema = new Schema<IPodcast>(
{
title: {
type: String,
required: true,
trim: true,
},
description: {
type: String,
default: '',
},
rssUrl: {
type: String,
required: true,
unique: true,
trim: true,
},
imageUrl: {
type: String,
default: null,
},
author: {
type: String,
trim: true,
},
category: {
type: [String],
default: [],
},
language: {
type: String,
default: 'en',
},
lastFetched: {
type: Date,
default: () => new Date(0), // Start with epoch
},
episodeCount: {
type: Number,
default: 0,
},
},
{
timestamps: true,
}
);
// Index for faster queries
podcastSchema.index({ rssUrl: 1 });
podcastSchema.index({ title: 'text', description: 'text', author: 'text' });
podcastSchema.index({ category: 1 });
export const Podcast = mongoose.model<IPodcast>('Podcast', podcastSchema);
+89
View File
@@ -0,0 +1,89 @@
import mongoose, { Schema, Document } from 'mongoose';
import bcrypt from 'bcryptjs';
export interface IUser extends Document {
email: string;
password: string;
username: string;
avatar?: string;
preferences: {
defaultPlaybackSpeed: number;
theme: 'light' | 'dark';
language: string;
};
createdAt: Date;
updatedAt: Date;
comparePassword(password: string): Promise<boolean>;
}
const userSchema = new Schema<IUser>(
{
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: true,
minlength: 6,
},
username: {
type: String,
required: true,
trim: true,
},
avatar: {
type: String,
default: null,
},
preferences: {
defaultPlaybackSpeed: {
type: Number,
default: 1,
enum: [0.75, 1, 1.25, 1.5, 1.75, 2],
},
theme: {
type: String,
default: 'light',
enum: ['light', 'dark'],
},
language: {
type: String,
default: 'en',
},
},
},
{
timestamps: true,
}
);
// Hash password before saving
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error as Error);
}
});
// Method to compare passwords
userSchema.methods.comparePassword = async function (password: string): Promise<boolean> {
return bcrypt.compare(password, this.password);
};
// Remove password from JSON output
userSchema.methods.toJSON = function () {
const obj = this.toObject();
delete obj.password;
return obj;
};
export const User = mongoose.model<IUser>('User', userSchema);
+57
View File
@@ -0,0 +1,57 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IUserProgress extends Document {
userId: mongoose.Types.ObjectId;
episodeId: mongoose.Types.ObjectId;
position: number; // seconds listened
isCompleted: boolean;
isSaved: boolean;
lastListenedAt: Date;
createdAt: Date;
updatedAt: Date;
}
const userProgressSchema = new Schema<IUserProgress>(
{
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
episodeId: {
type: Schema.Types.ObjectId,
ref: 'Episode',
required: true,
index: true,
},
position: {
type: Number,
default: 0,
min: 0,
},
isCompleted: {
type: Boolean,
default: false,
},
isSaved: {
type: Boolean,
default: false,
},
lastListenedAt: {
type: Date,
default: () => new Date(),
},
},
{
timestamps: true,
}
);
// Compound index for unique progress per user per episode
userProgressSchema.index({ userId: 1, episodeId: 1 }, { unique: true });
userProgressSchema.index({ userId: 1, lastListenedAt: -1 });
userProgressSchema.index({ userId: 1, isCompleted: 1 });
userProgressSchema.index({ userId: 1, isSaved: 1 });
export const UserProgress = mongoose.model<IUserProgress>('UserProgress', userProgressSchema);
+47
View File
@@ -0,0 +1,47 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IUserSubscription extends Document {
userId: mongoose.Types.ObjectId;
podcastId: mongoose.Types.ObjectId;
subscribedAt: Date;
isFavorite: boolean;
createdAt: Date;
updatedAt: Date;
}
const userSubscriptionSchema = new Schema<IUserSubscription>(
{
userId: {
type: Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true,
},
podcastId: {
type: Schema.Types.ObjectId,
ref: 'Podcast',
required: true,
index: true,
},
subscribedAt: {
type: Date,
default: () => new Date(),
},
isFavorite: {
type: Boolean,
default: false,
},
},
{
timestamps: true,
}
);
// Compound index to ensure unique subscription per user
userSubscriptionSchema.index({ userId: 1, podcastId: 1 }, { unique: true });
userSubscriptionSchema.index({ userId: 1, isFavorite: 1 });
export const UserSubscription = mongoose.model<IUserSubscription>(
'UserSubscription',
userSubscriptionSchema
);
+57
View File
@@ -0,0 +1,57 @@
import jwt from 'jsonwebtoken';
import { IUser } from '../models/User';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || '30d';
export interface JwtPayload {
id: string;
email: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export const generateTokens = (user: IUser): TokenPair => {
const payload: JwtPayload = {
id: user._id.toString(),
email: user.email,
};
const accessToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
const refreshToken = jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_REFRESH_EXPIRES_IN,
});
return { accessToken, refreshToken };
};
export const verifyAccessToken = (token: string): JwtPayload => {
try {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
} catch (error) {
throw new Error('Invalid or expired token');
}
};
export const verifyRefreshToken = (token: string): JwtPayload => {
try {
return jwt.verify(token, JWT_SECRET) as JwtPayload;
} catch (error) {
throw new Error('Invalid or expired refresh token');
}
};
export const decodeToken = (token: string): JwtPayload | null => {
try {
return jwt.decode(token) as JwtPayload | null;
} catch {
return null;
}
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+79
View File
@@ -0,0 +1,79 @@
version: '3.8'
services:
mongo:
image: mongo:latest
container_name: podcastic-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: podcastic
MONGO_INITDB_ROOT_USERNAME: podcastic
MONGO_INITDB_ROOT_PASSWORD: podcastic_dev
volumes:
- mongo_data:/data/db
networks:
- podcastic-network
redis:
image: redis:7-alpine
container_name: podcastic-redis
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- podcastic-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: podcastic-backend
restart: unless-stopped
ports:
- "5000:5000"
environment:
NODE_ENV: development
MONGODB_URI: mongodb://podcastic:podcastic_dev@mongo:27017/podcastic
REDIS_URL: redis://redis:6379
JWT_SECRET: dev_secret_key_change_in_prod
depends_on:
- mongo
- redis
volumes:
- ./backend/src:/app/src
- /app/node_modules
networks:
- podcastic-network
command: npm run dev
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: podcastic-frontend
restart: unless-stopped
ports:
- "3000:3000"
environment:
VITE_API_URL: http://localhost:5000/api
depends_on:
- backend
volumes:
- ./frontend/src:/app/src
- /app/node_modules
networks:
- podcastic-network
command: npm run dev
volumes:
mongo_data:
redis_data:
networks:
podcastic-network:
driver: bridge
+49
View File
@@ -0,0 +1,49 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the app
RUN npm run build
# Production stage - use nginx for serving
FROM nginx:alpine
# Copy nginx config
RUN echo 'server {\n\
listen 3000;\n\
server_name _;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
\n\
location / {\n\
try_files $uri $uri/ /index.html;\n\
}\n\
\n\
location ~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\n\
expires 1y;\n\
add_header Cache-Control "public, immutable";\n\
}\n\
}' > /etc/nginx/conf.d/default.conf
# Copy built app from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Podcastic - Stream your favorite podcasts" />
<meta name="theme-color" content="#ffffff" />
<title>Podcastic - Podcast Streaming</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "podcastic-frontend",
"version": "1.0.0",
"description": "Podcastic - Multi-user podcast streaming application frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.16.0",
"@tanstack/react-query": "^5.1.0",
"zustand": "^4.4.1",
"@heroicons/react": "^2.0.18",
"axios": "^1.5.0",
"lucide-react": "^0.292.0"
},
"devDependencies": {
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-react": "^4.1.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.50.0",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+45
View File
@@ -0,0 +1,45 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './styles/globals.css';
// Placeholder pages
const 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({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+110
View File
@@ -0,0 +1,110 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
@apply box-border;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-light-50 text-light-900 antialiased;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
}
/* Custom scrollbar */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-light-100;
}
::-webkit-scrollbar-thumb {
@apply bg-light-300 rounded-full hover:bg-light-400;
}
/* Apple TV Style Glassmorphism */
.glass {
@apply backdrop-blur-xl bg-white/30 border border-white/20 rounded-2xl;
}
.glass-dark {
@apply backdrop-blur-xl bg-black/30 border border-white/10 rounded-2xl;
}
/* Glossy effect with shadows */
.glossy {
@apply shadow-soft;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glossy:hover {
@apply shadow-lg;
transform: translateY(-2px);
}
/* Smooth gradient backgrounds */
.gradient-light {
background: linear-gradient(135deg, #f9fafb 0%, #f3f4f6 100%);
}
.gradient-primary {
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
/* Custom components */
.btn-primary {
@apply px-6 py-3 rounded-lg bg-blue-500 text-white font-medium hover:bg-blue-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-secondary {
@apply px-6 py-3 rounded-lg bg-light-200 text-light-900 font-medium hover:bg-light-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed;
}
.card {
@apply bg-white rounded-2xl p-6 shadow-soft;
}
.input {
@apply w-full px-4 py-3 rounded-lg border border-light-200 bg-white focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors;
}
/* Loading spinner */
.spinner {
@apply inline-block h-4 w-4 animate-spin rounded-full border-2 border-solid border-current border-r-transparent;
}
+42
View File
@@ -0,0 +1,42 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
light: {
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
900: '#111827',
}
},
backdropFilter: {
'none': 'none',
'blur': 'blur(10px)',
},
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
},
borderRadius: {
xl: '1rem',
'2xl': '1.5rem',
'3xl': '2rem',
},
boxShadow: {
'soft': '0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03)',
'glow': '0 0 30px rgba(59, 130, 246, 0.1)',
}
},
},
plugins: [],
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
})