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