mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
feat: initialize Podcastic project foundation - setup backend and frontend
This commit is contained in:
commit
8df1161023
31 files changed
+1626
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(rtk git:*)",
|
||||||
|
"Bash(rtk npm:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user