From 4a6e2c307cfb230bb5d07e74e3ea015d94dc69da Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Thu, 29 Jan 2026 00:24:10 -0500 Subject: [PATCH] setup features --- .dockerignore | 2 + Dockerfile | 30 +++ README.md | 125 +++++++++-- SETUP_CHECKLIST.md | 132 +++++++++++ TMDB_SETUP.md | 36 +++ backend/.env.example | 12 + backend/Dockerfile | 7 - backend/package.json | 8 +- backend/src/db/index.js | 56 +++++ backend/src/db/init.sql | 78 +++++++ backend/src/index.js | 52 ++++- backend/src/middleware/auth.js | 19 ++ backend/src/routes/auth.js | 122 +++++++++++ backend/src/routes/challenges.js | 218 +++++++++++++++++++ backend/src/routes/friends.js | 169 ++++++++++++++ backend/src/routes/leaderboard.js | 124 +++++++++++ backend/src/routes/predictions.js | 140 ++++++++++++ backend/src/routes/tmdb.js | 74 +++++++ docker-compose.yml | 37 ++-- frontend/Dockerfile | 7 - frontend/package.json | 4 +- frontend/src/App.css | 232 ++++++++++++++++++++ frontend/src/AuthContext.jsx | 56 +++++ frontend/src/api.js | 158 ++++++++++++++ frontend/src/main.jsx | 70 +++++- frontend/src/pages/ChallengeDetail.jsx | 290 +++++++++++++++++++++++++ frontend/src/pages/ChallengeList.jsx | 165 ++++++++++++++ frontend/src/pages/Friends.jsx | 198 +++++++++++++++++ frontend/src/pages/Leaderboard.jsx | 89 ++++++++ frontend/src/pages/Login.jsx | 64 ++++++ frontend/src/pages/Profile.jsx | 82 +++++++ frontend/src/pages/Register.jsx | 76 +++++++ frontend/vite.config.js | 13 ++ prod-compose.yml | 17 +- 34 files changed, 2891 insertions(+), 71 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 SETUP_CHECKLIST.md create mode 100644 TMDB_SETUP.md create mode 100644 backend/.env.example delete mode 100644 backend/Dockerfile create mode 100644 backend/src/db/index.js create mode 100644 backend/src/db/init.sql create mode 100644 backend/src/middleware/auth.js create mode 100644 backend/src/routes/auth.js create mode 100644 backend/src/routes/challenges.js create mode 100644 backend/src/routes/friends.js create mode 100644 backend/src/routes/leaderboard.js create mode 100644 backend/src/routes/predictions.js create mode 100644 backend/src/routes/tmdb.js delete mode 100644 frontend/Dockerfile create mode 100644 frontend/src/App.css create mode 100644 frontend/src/AuthContext.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/pages/ChallengeDetail.jsx create mode 100644 frontend/src/pages/ChallengeList.jsx create mode 100644 frontend/src/pages/Friends.jsx create mode 100644 frontend/src/pages/Leaderboard.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Profile.jsx create mode 100644 frontend/src/pages/Register.jsx create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..119f8ac --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Stage 1: Build Frontend +FROM node:18-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Setup Backend +FROM node:18-alpine + +WORKDIR /app + +# Copy backend files +COPY backend/package*.json ./ +RUN npm install --production + +COPY backend/ ./ + +# Copy built frontend +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Expose port +EXPOSE 80 + +ENV PORT=80 + +# Start the server +CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 5521088..21d94ca 100644 --- a/README.md +++ b/README.md @@ -3,34 +3,125 @@ A web app for tracking predictions and points in TV/movie challenges with friends. ## Features -- Register/login with email and password -- Create and join challenges for shows/movies -- Make and approve predictions -- Mobile-first, modern UI +- 🔐 Register/login with email and password (JWT authentication) +- 🎬 Create challenges for shows/movies with TMDB integration +- 📝 Make and validate predictions with friends +- 🏆 Leaderboards (per-challenge and global) +- 👥 Friend system for easy invitations +- 📱 Mobile-first, modern dark UI ## Tech Stack -- Frontend: React (Vite) -- Backend: Node.js (Express) -- Database: PostgreSQL -- Auth: JWT (email/password) -- Dockerized, self-hosted +- **Frontend:** React 18, React Router, Vite +- **Backend:** Node.js, Express +- **Database:** MariaDB +- **Auth:** JWT with bcrypt +- **APIs:** The Movie Database (TMDB) with caching +- **Deployment:** Docker, self-hosted via Gitea + Portainer ## Getting Started ### Prerequisites - Docker & Docker Compose +- TMDB API Key (free from https://www.themoviedb.org/settings/api) -### Setup -1. Copy `.env.example` to `.env` and fill in secrets. -2. Build and start all services: - ```sh +### Local Development + +1. **Copy environment file:** + ```bash + cp backend/.env.example backend/.env + ``` + +2. **Add your TMDB API key** to `backend/.env`: + ``` + TMDB_API_KEY=your_actual_key_here + ``` + +3. **Build and start all services:** + ```bash docker compose up --build ``` -3. Access the frontend at http://localhost:5173 -4. API runs at http://localhost:4000 -## Deployment -- See `prod-compose.yml` for production deployment. +4. **Access the app:** + - Frontend/App: http://localhost:4000 + - Database: localhost:3306 + +The database will auto-initialize with the required schema on first run. + +## Production Deployment + +1. **Update `prod-compose.yml`** with your environment variables: + - Set a strong `JWT_SECRET` + - Set a strong `DB_PASSWORD` + - Add your `TMDB_API_KEY` + +2. **The Gitea workflow** (`.gitea/workflows/rebuild-prod.yaml`) will: + - Build the Docker image + - Push to your registry + - Deploy via Portainer API + +3. **Environment variables for Gitea secrets:** + - `PROD_ENV`: Base64-encoded `.env` file with production values + - `PORTAINER_TOKEN`: Your Portainer API token + +## How It Works + +### User Flow +1. **Register/Login** - Create an account or sign in +2. **Create Challenge** - Search for a TV show or movie via TMDB +3. **Invite Friends** - Add participants by username/email +4. **Make Predictions** - Submit your predictions about the show +5. **Validate Predictions** - Approve or invalidate others' predictions (not your own) +6. **Track Points** - View leaderboards and profiles + +### Key Features +- **TMDB Integration:** Search shows/movies with autocomplete, cached for 7 days +- **Friend System:** Dedicated friends page + auto-friends from challenge participation +- **Leaderboards:** Per-challenge and global rankings +- **Profile Stats:** Total points, pending predictions, challenges created/joined +- **Responsive Design:** Mobile-first with dark theme + +## API Endpoints + +### Auth +- `POST /api/auth/register` - Register new user +- `POST /api/auth/login` - Login user +- `GET /api/auth/me` - Get current user + +### Challenges +- `GET /api/challenges` - List user's challenges +- `GET /api/challenges/:id` - Get challenge details +- `POST /api/challenges` - Create new challenge +- `POST /api/challenges/:id/invite` - Invite users +- `POST /api/challenges/:id/respond` - Accept/reject invitation + +### Predictions +- `GET /api/predictions/challenge/:id` - List predictions +- `POST /api/predictions` - Create prediction +- `POST /api/predictions/:id/validate` - Validate/invalidate + +### Friends +- `GET /api/friends` - List friends +- `GET /api/friends/search` - Search users +- `POST /api/friends/request` - Send friend request +- `POST /api/friends/respond` - Accept/reject request +- `GET /api/friends/requests` - Pending requests + +### Leaderboard +- `GET /api/leaderboard/challenge/:id` - Challenge leaderboard +- `GET /api/leaderboard/global` - Global leaderboard +- `GET /api/leaderboard/profile/:id?` - User profile stats + +### TMDB +- `GET /api/tmdb/search?q=query` - Search shows/movies + +## Database Schema + +- **users** - User accounts +- **challenges** - TV/movie challenges +- **challenge_participants** - Challenge memberships +- **predictions** - User predictions +- **friendships** - Friend relationships +- **tmdb_cache** - Cached TMDB API responses ## License MIT diff --git a/SETUP_CHECKLIST.md b/SETUP_CHECKLIST.md new file mode 100644 index 0000000..35d6021 --- /dev/null +++ b/SETUP_CHECKLIST.md @@ -0,0 +1,132 @@ +# Setup Checklist + +## Before You Start + +- [ ] Docker and Docker Compose installed +- [ ] TMDB API key obtained (see [TMDB_SETUP.md](TMDB_SETUP.md)) +- [ ] Git/Gitea repository initialized + +## Local Development Setup + +1. **Configure Environment:** + ```bash + cp backend/.env.example backend/.env + ``` + +2. **Edit `backend/.env`:** + - [ ] Set `TMDB_API_KEY` to your actual TMDB API key + - [ ] Change `JWT_SECRET` if desired (optional for dev) + - [ ] Verify database settings match docker-compose.yml + +3. **Start Services:** + ```bash + docker compose up --build + ``` + +4. **Verify:** + - [ ] App accessible at http://localhost:4000 + - [ ] Can register a new account + - [ ] Can search for shows/movies + - [ ] Database initialized successfully (check logs) + +## Production Deployment + +1. **Prepare Environment Variables:** + + Create a production `.env` file with: + ```bash + PORT=80 + JWT_SECRET= + DB_HOST=db + DB_USER=root + DB_PASSWORD= + DB_NAME=whats_the_point + TMDB_API_KEY= + ``` + +2. **Base64 encode your `.env` for Gitea:** + ```bash + # On Linux/Mac: + cat .env | base64 -w 0 + + # On Windows PowerShell: + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Content .env -Raw))) + ``` + +3. **Configure Gitea Secrets:** + - [ ] `PROD_ENV` - Base64 encoded environment file + - [ ] `PORTAINER_TOKEN` - Your Portainer API token + +4. **Update `prod-compose.yml`:** + - [ ] Verify volume paths match your system + - [ ] Update image registry URL if needed + - [ ] Verify port mappings + +5. **Verify Gitea Workflow:** + - [ ] Check `.gitea/workflows/rebuild-prod.yaml` + - [ ] Update `IMAGE_TAG` with your registry URL + - [ ] Update `ENDPOINT_NAME` to match your Portainer endpoint + - [ ] Update `STACK_NAME` if desired + +6. **Deploy:** + - [ ] Push to main branch + - [ ] Monitor Gitea Actions + - [ ] Check Portainer for successful deployment + - [ ] Verify app is accessible on production domain/IP + +## Post-Deployment + +- [ ] Test registration and login +- [ ] Create a test challenge +- [ ] Invite a friend (test email/username search) +- [ ] Make and validate predictions +- [ ] Check leaderboards +- [ ] Test on mobile devices + +## Troubleshooting + +### Database Connection Issues +- Check if database container is running: `docker ps` +- Check database logs: `docker logs ` +- Verify DB credentials in environment variables + +### TMDB API Not Working +- Verify API key is correct in `.env` +- Check TMDB API status: https://www.themoviedb.org/ +- Review backend logs for API errors + +### Frontend Not Loading +- Check if backend is serving static files +- Verify frontend built successfully (check Docker logs) +- Clear browser cache + +### Docker Build Failures +- Ensure all dependencies are in package.json files +- Check Dockerfile syntax +- Verify node_modules are not in build context (.dockerignore) + +## Maintenance + +### Database Backups +```bash +# Backup +docker exec mysqldump -u root -p whats_the_point > backup.sql + +# Restore +docker exec -i mysql -u root -p whats_the_point < backup.sql +``` + +### Viewing Logs +```bash +# Docker Compose (dev) +docker compose logs -f web +docker compose logs -f db + +# Production (Portainer) +# Use Portainer UI to view logs +``` + +### Updating +1. Pull latest changes from repository +2. Push to main branch (triggers Gitea workflow) +3. Workflow automatically rebuilds and redeploys diff --git a/TMDB_SETUP.md b/TMDB_SETUP.md new file mode 100644 index 0000000..ca9187d --- /dev/null +++ b/TMDB_SETUP.md @@ -0,0 +1,36 @@ +# Getting Your TMDB API Key + +## Steps: + +1. **Go to TMDB website:** + https://www.themoviedb.org/ + +2. **Create a free account** (if you don't have one) + +3. **Go to Settings → API:** + https://www.themoviedb.org/settings/api + +4. **Request an API Key:** + - Click "Request an API Key" + - Choose "Developer" option + - Fill in the application details: + - Application Name: What's The Point + - Application URL: Your domain or localhost + - Application Summary: Personal movie/TV show challenge tracker + - Accept terms and submit + +5. **Copy your API Key (v3 auth)** + - You'll receive an API key immediately + - Copy the "API Key (v3 auth)" value + +6. **Add it to your `.env` file:** + ``` + TMDB_API_KEY=your_key_here + ``` + +## Rate Limits: +- Free tier: 40 requests per 10 seconds +- Our app caches results for 7 days to minimize API calls + +## Note: +The TMDB API is free for personal use and provides access to millions of movies and TV shows with poster images, release dates, and more. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..356fdec --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +# Backend Environment Variables +PORT=4000 +JWT_SECRET=your_super_secret_jwt_key_change_this_in_production + +# Database Configuration +DB_HOST=db +DB_USER=root +DB_PASSWORD=rootpassword +DB_NAME=whats_the_point + +# TMDB API Key (get from https://www.themoviedb.org/settings/api) +TMDB_API_KEY=your_tmdb_api_key_here diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index dbf616a..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM node:20-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm install -COPY . . -EXPOSE 4000 -CMD ["npm", "run", "dev"] diff --git a/backend/package.json b/backend/package.json index 22f6cbe..86c46ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,14 +4,16 @@ "main": "src/index.js", "type": "module", "scripts": { - "dev": "node src/index.js" + "dev": "node src/index.js", + "start": "node src/index.js" }, "dependencies": { "express": "^4.18.2", - "pg": "^8.11.3", + "mysql2": "^3.6.5", "jsonwebtoken": "^9.0.2", "bcryptjs": "^2.4.3", "dotenv": "^16.4.5", - "cors": "^2.8.5" + "cors": "^2.8.5", + "node-fetch": "^3.3.2" } } diff --git a/backend/src/db/index.js b/backend/src/db/index.js new file mode 100644 index 0000000..df1e692 --- /dev/null +++ b/backend/src/db/index.js @@ -0,0 +1,56 @@ +import mysql from 'mysql2/promise'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +let pool; + +export const initDB = async () => { + const dbConfig = { + host: process.env.DB_HOST || 'db', + user: process.env.DB_USER || 'root', + password: process.env.DB_PASSWORD || 'root', + database: process.env.DB_NAME || 'whats_the_point', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + multipleStatements: true + }; + + // Create pool + pool = mysql.createPool(dbConfig); + + // Test connection and run init script + try { + const connection = await pool.getConnection(); + console.log('✅ Database connected'); + + // Read and execute init script + const initSQL = fs.readFileSync(path.join(__dirname, 'init.sql'), 'utf8'); + await connection.query(initSQL); + console.log('✅ Database schema initialized'); + + connection.release(); + } catch (error) { + console.error('❌ Database connection error:', error); + throw error; + } + + return pool; +}; + +export const getDB = () => { + if (!pool) { + throw new Error('Database not initialized. Call initDB() first.'); + } + return pool; +}; + +export const query = async (sql, params) => { + const db = getDB(); + const [results] = await db.execute(sql, params); + return results; +}; diff --git a/backend/src/db/init.sql b/backend/src/db/init.sql new file mode 100644 index 0000000..42282f4 --- /dev/null +++ b/backend/src/db/init.sql @@ -0,0 +1,78 @@ +-- Database initialization script for What's The Point + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_email (email), + INDEX idx_username (username) +); + +CREATE TABLE IF NOT EXISTS challenges ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(500) NOT NULL, + cover_image_url VARCHAR(500), + tmdb_id INT, + media_type ENUM('movie', 'tv') DEFAULT 'movie', + created_by INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_created_by (created_by) +); + +CREATE TABLE IF NOT EXISTS challenge_participants ( + id INT AUTO_INCREMENT PRIMARY KEY, + challenge_id INT NOT NULL, + user_id INT NOT NULL, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + invited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + responded_at TIMESTAMP NULL, + FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_participant (challenge_id, user_id), + INDEX idx_user_id (user_id), + INDEX idx_status (status) +); + +CREATE TABLE IF NOT EXISTS predictions ( + id INT AUTO_INCREMENT PRIMARY KEY, + challenge_id INT NOT NULL, + user_id INT NOT NULL, + content TEXT NOT NULL, + status ENUM('pending', 'validated', 'invalidated') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + validated_by INT NULL, + validated_at TIMESTAMP NULL, + FOREIGN KEY (challenge_id) REFERENCES challenges(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (validated_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_challenge_id (challenge_id), + INDEX idx_user_id (user_id), + INDEX idx_status (status) +); + +CREATE TABLE IF NOT EXISTS friendships ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + friend_id INT NOT NULL, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (friend_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE KEY unique_friendship (user_id, friend_id), + INDEX idx_user_id (user_id), + INDEX idx_friend_id (friend_id), + INDEX idx_status (status) +); + +CREATE TABLE IF NOT EXISTS tmdb_cache ( + id INT AUTO_INCREMENT PRIMARY KEY, + query VARCHAR(255) NOT NULL, + media_type VARCHAR(20) NOT NULL, + response_data JSON NOT NULL, + cached_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_query (query, media_type), + INDEX idx_query (query) +); diff --git a/backend/src/index.js b/backend/src/index.js index 6e781ad..8668242 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,17 +1,59 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { initDB } from './db/index.js'; +import authRoutes from './routes/auth.js'; +import challengeRoutes from './routes/challenges.js'; +import predictionRoutes from './routes/predictions.js'; +import friendRoutes from './routes/friends.js'; +import tmdbRoutes from './routes/tmdb.js'; +import leaderboardRoutes from './routes/leaderboard.js'; + dotenv.config(); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + const app = express(); + +// Middleware app.use(cors()); app.use(express.json()); -app.get('/', (req, res) => { - res.json({ message: "What's The Point API" }); +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/challenges', challengeRoutes); +app.use('/api/predictions', predictionRoutes); +app.use('/api/friends', friendRoutes); +app.use('/api/tmdb', tmdbRoutes); +app.use('/api/leaderboard', leaderboardRoutes); + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', message: "What's The Point API" }); +}); + +// Serve static frontend files (for production) +const frontendPath = path.join(__dirname, '../../frontend/dist'); +app.use(express.static(frontendPath)); + +// Serve index.html for all non-API routes (SPA support) +app.get('*', (req, res) => { + res.sendFile(path.join(frontendPath, 'index.html')); }); const PORT = process.env.PORT || 4000; -app.listen(PORT, () => { - console.log(`API running on port ${PORT}`); -}); + +// Initialize database and start server +initDB() + .then(() => { + app.listen(PORT, '0.0.0.0', () => { + console.log(`✅ Server running on port ${PORT}`); + }); + }) + .catch(err => { + console.error('Failed to start server:', err); + process.exit(1); + }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..7760577 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,19 @@ +import jwt from 'jsonwebtoken'; + +export const authMiddleware = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + req.user = decoded; // { userId, email, username } + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } +}; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..ebf9349 --- /dev/null +++ b/backend/src/routes/auth.js @@ -0,0 +1,122 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { query } from '../db/index.js'; + +const router = express.Router(); + +// Register +router.post('/register', async (req, res) => { + try { + const { email, username, password } = req.body; + + if (!email || !username || !password) { + return res.status(400).json({ error: 'All fields required' }); + } + + // Check if user exists + const existing = await query( + 'SELECT id FROM users WHERE email = ? OR username = ?', + [email, username] + ); + + if (existing.length > 0) { + return res.status(400).json({ error: 'User already exists' }); + } + + // Hash password + const password_hash = await bcrypt.hash(password, 10); + + // Create user + const result = await query( + 'INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)', + [email, username, password_hash] + ); + + const userId = result.insertId; + + // Generate token + const token = jwt.sign( + { userId, email, username }, + process.env.JWT_SECRET, + { expiresIn: '30d' } + ); + + res.json({ token, user: { id: userId, email, username } }); + } catch (error) { + console.error('Register error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +// Login +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }); + } + + // Find user + const users = await query( + 'SELECT id, email, username, password_hash FROM users WHERE email = ?', + [email] + ); + + if (users.length === 0) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const user = users[0]; + + // Check password + const validPassword = await bcrypt.compare(password, user.password_hash); + if (!validPassword) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Generate token + const token = jwt.sign( + { userId: user.id, email: user.email, username: user.username }, + process.env.JWT_SECRET, + { expiresIn: '30d' } + ); + + res.json({ + token, + user: { id: user.id, email: user.email, username: user.username } + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Get current user +router.get('/me', async (req, res) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'No token provided' }); + } + + const token = authHeader.substring(7); + const decoded = jwt.verify(token, process.env.JWT_SECRET); + + const users = await query( + 'SELECT id, email, username, created_at FROM users WHERE id = ?', + [decoded.userId] + ); + + if (users.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ user: users[0] }); + } catch (error) { + res.status(401).json({ error: 'Invalid token' }); + } +}); + +export default router; diff --git a/backend/src/routes/challenges.js b/backend/src/routes/challenges.js new file mode 100644 index 0000000..cb10f30 --- /dev/null +++ b/backend/src/routes/challenges.js @@ -0,0 +1,218 @@ +import express from 'express'; +import { query } from '../db/index.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = express.Router(); + +// Get all challenges for the current user +router.get('/', authMiddleware, async (req, res) => { + try { + const challenges = await query( + `SELECT + c.*, + u.username as creator_username, + cp.status as participation_status, + (SELECT COUNT(*) FROM predictions WHERE challenge_id = c.id AND status = 'validated' AND user_id = ?) as my_points + FROM challenges c + INNER JOIN users u ON c.created_by = u.id + LEFT JOIN challenge_participants cp ON cp.challenge_id = c.id AND cp.user_id = ? + WHERE c.created_by = ? OR (cp.user_id = ? AND cp.status = 'accepted') + ORDER BY c.created_at DESC`, + [req.user.userId, req.user.userId, req.user.userId, req.user.userId] + ); + + res.json({ challenges }); + } catch (error) { + console.error('Get challenges error:', error); + res.status(500).json({ error: 'Failed to fetch challenges' }); + } +}); + +// Get a single challenge with details +router.get('/:id', authMiddleware, async (req, res) => { + try { + const challengeId = req.params.id; + + // Get challenge details + const challenges = await query( + `SELECT c.*, u.username as creator_username + FROM challenges c + INNER JOIN users u ON c.created_by = u.id + WHERE c.id = ?`, + [challengeId] + ); + + if (challenges.length === 0) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + const challenge = challenges[0]; + + // Check if user has access + const access = await query( + `SELECT * FROM challenge_participants + WHERE challenge_id = ? AND user_id = ? AND status = 'accepted'`, + [challengeId, req.user.userId] + ); + + if (challenge.created_by !== req.user.userId && access.length === 0) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Get participants with their points + const participants = await query( + `SELECT + u.id, u.username, u.email, + cp.status, + (SELECT COUNT(*) FROM predictions WHERE challenge_id = ? AND user_id = u.id AND status = 'validated') as points + FROM challenge_participants cp + INNER JOIN users u ON cp.user_id = u.id + WHERE cp.challenge_id = ? + ORDER BY points DESC`, + [challengeId, challengeId] + ); + + // Get creator's points + const creatorPoints = await query( + `SELECT COUNT(*) as points FROM predictions + WHERE challenge_id = ? AND user_id = ? AND status = 'validated'`, + [challengeId, challenge.created_by] + ); + + res.json({ + challenge, + participants, + creator_points: creatorPoints[0].points + }); + } catch (error) { + console.error('Get challenge error:', error); + res.status(500).json({ error: 'Failed to fetch challenge' }); + } +}); + +// Create a new challenge +router.post('/', authMiddleware, async (req, res) => { + try { + const { title, cover_image_url, tmdb_id, media_type } = req.body; + + if (!title) { + return res.status(400).json({ error: 'Title is required' }); + } + + const result = await query( + 'INSERT INTO challenges (title, cover_image_url, tmdb_id, media_type, created_by) VALUES (?, ?, ?, ?, ?)', + [title, cover_image_url || null, tmdb_id || null, media_type || 'movie', req.user.userId] + ); + + const challenge = { + id: result.insertId, + title, + cover_image_url, + tmdb_id, + media_type, + created_by: req.user.userId, + creator_username: req.user.username + }; + + res.json({ challenge }); + } catch (error) { + console.error('Create challenge error:', error); + res.status(500).json({ error: 'Failed to create challenge' }); + } +}); + +// Invite users to a challenge +router.post('/:id/invite', authMiddleware, async (req, res) => { + try { + const challengeId = req.params.id; + const { user_ids, emails } = req.body; + + // Verify user owns the challenge or is a participant + const challenges = await query( + 'SELECT * FROM challenges WHERE id = ?', + [challengeId] + ); + + if (challenges.length === 0) { + return res.status(404).json({ error: 'Challenge not found' }); + } + + const challenge = challenges[0]; + + if (challenge.created_by !== req.user.userId) { + // Check if user is an accepted participant + const participation = await query( + 'SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ? AND status = "accepted"', + [challengeId, req.user.userId] + ); + + if (participation.length === 0) { + return res.status(403).json({ error: 'Only challenge participants can invite others' }); + } + } + + const invitedUsers = []; + + // Invite by user IDs + if (user_ids && Array.isArray(user_ids)) { + for (const userId of user_ids) { + try { + await query( + 'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")', + [challengeId, userId] + ); + invitedUsers.push(userId); + } catch (err) { + // Ignore duplicate key errors + } + } + } + + // Invite by emails + if (emails && Array.isArray(emails)) { + for (const email of emails) { + const users = await query('SELECT id FROM users WHERE email = ?', [email]); + if (users.length > 0) { + try { + await query( + 'INSERT INTO challenge_participants (challenge_id, user_id, status) VALUES (?, ?, "pending")', + [challengeId, users[0].id] + ); + invitedUsers.push(users[0].id); + } catch (err) { + // Ignore duplicate key errors + } + } + } + } + + res.json({ invited: invitedUsers.length }); + } catch (error) { + console.error('Invite error:', error); + res.status(500).json({ error: 'Failed to send invites' }); + } +}); + +// Accept/reject challenge invitation +router.post('/:id/respond', authMiddleware, async (req, res) => { + try { + const challengeId = req.params.id; + const { status } = req.body; // 'accepted' or 'rejected' + + if (!['accepted', 'rejected'].includes(status)) { + return res.status(400).json({ error: 'Invalid status' }); + } + + await query( + 'UPDATE challenge_participants SET status = ?, responded_at = NOW() WHERE challenge_id = ? AND user_id = ?', + [status, challengeId, req.user.userId] + ); + + res.json({ status }); + } catch (error) { + console.error('Respond error:', error); + res.status(500).json({ error: 'Failed to respond to invitation' }); + } +}); + +export default router; diff --git a/backend/src/routes/friends.js b/backend/src/routes/friends.js new file mode 100644 index 0000000..b3feac0 --- /dev/null +++ b/backend/src/routes/friends.js @@ -0,0 +1,169 @@ +import express from 'express'; +import { query } from '../db/index.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = express.Router(); + +// Search for users by username or email +router.get('/search', authMiddleware, async (req, res) => { + try { + const { q } = req.query; + + if (!q || q.trim().length < 2) { + return res.json({ users: [] }); + } + + const searchTerm = `%${q.trim()}%`; + + const users = await query( + `SELECT id, username, email + FROM users + WHERE (username LIKE ? OR email LIKE ?) AND id != ? + LIMIT 20`, + [searchTerm, searchTerm, req.user.userId] + ); + + res.json({ users }); + } catch (error) { + console.error('User search error:', error); + res.status(500).json({ error: 'Search failed' }); + } +}); + +// Get all friends +router.get('/', authMiddleware, async (req, res) => { + try { + // Get accepted friendships (bidirectional) + const friends = await query( + `SELECT DISTINCT + u.id, u.username, u.email, + (SELECT COUNT(*) FROM predictions WHERE user_id = u.id AND status = 'validated') as total_points + FROM users u + WHERE u.id IN ( + SELECT friend_id FROM friendships WHERE user_id = ? AND status = 'accepted' + UNION + SELECT user_id FROM friendships WHERE friend_id = ? AND status = 'accepted' + ) + ORDER BY u.username`, + [req.user.userId, req.user.userId] + ); + + // Also get people who have shared challenges with me (auto-friends from challenges) + const challengeFriends = await query( + `SELECT DISTINCT + u.id, u.username, u.email, + (SELECT COUNT(*) FROM predictions WHERE user_id = u.id AND status = 'validated') as total_points + FROM users u + WHERE u.id IN ( + SELECT DISTINCT cp.user_id + FROM challenge_participants cp + INNER JOIN challenge_participants my_cp ON cp.challenge_id = my_cp.challenge_id + WHERE my_cp.user_id = ? AND cp.user_id != ? AND cp.status = 'accepted' AND my_cp.status = 'accepted' + UNION + SELECT DISTINCT c.created_by + FROM challenges c + INNER JOIN challenge_participants cp ON cp.challenge_id = c.id + WHERE cp.user_id = ? AND c.created_by != ? AND cp.status = 'accepted' + ) + AND u.id NOT IN (${friends.map(() => '?').join(',') || 'NULL'}) + ORDER BY u.username`, + [req.user.userId, req.user.userId, req.user.userId, req.user.userId, ...friends.map(f => f.id)] + ); + + res.json({ + friends, + challenge_friends: challengeFriends + }); + } catch (error) { + console.error('Get friends error:', error); + res.status(500).json({ error: 'Failed to fetch friends' }); + } +}); + +// Send friend request +router.post('/request', authMiddleware, async (req, res) => { + try { + const { user_id } = req.body; + + if (!user_id) { + return res.status(400).json({ error: 'User ID required' }); + } + + if (user_id === req.user.userId) { + return res.status(400).json({ error: 'Cannot add yourself as friend' }); + } + + // Check if already friends or request exists + const existing = await query( + `SELECT * FROM friendships + WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`, + [req.user.userId, user_id, user_id, req.user.userId] + ); + + if (existing.length > 0) { + return res.status(400).json({ error: 'Friend request already exists or you are already friends' }); + } + + await query( + 'INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, "pending")', + [req.user.userId, user_id] + ); + + res.json({ success: true }); + } catch (error) { + console.error('Friend request error:', error); + res.status(500).json({ error: 'Failed to send friend request' }); + } +}); + +// Accept/reject friend request +router.post('/respond', authMiddleware, async (req, res) => { + try { + const { friendship_id, status } = req.body; + + if (!['accepted', 'rejected'].includes(status)) { + return res.status(400).json({ error: 'Invalid status' }); + } + + // Verify the request is for the current user + const friendships = await query( + 'SELECT * FROM friendships WHERE id = ? AND friend_id = ?', + [friendship_id, req.user.userId] + ); + + if (friendships.length === 0) { + return res.status(404).json({ error: 'Friend request not found' }); + } + + await query( + 'UPDATE friendships SET status = ? WHERE id = ?', + [status, friendship_id] + ); + + res.json({ status }); + } catch (error) { + console.error('Respond to friend request error:', error); + res.status(500).json({ error: 'Failed to respond to friend request' }); + } +}); + +// Get pending friend requests +router.get('/requests', authMiddleware, async (req, res) => { + try { + const requests = await query( + `SELECT f.id, f.created_at, u.id as user_id, u.username, u.email + FROM friendships f + INNER JOIN users u ON f.user_id = u.id + WHERE f.friend_id = ? AND f.status = 'pending' + ORDER BY f.created_at DESC`, + [req.user.userId] + ); + + res.json({ requests }); + } catch (error) { + console.error('Get friend requests error:', error); + res.status(500).json({ error: 'Failed to fetch friend requests' }); + } +}); + +export default router; diff --git a/backend/src/routes/leaderboard.js b/backend/src/routes/leaderboard.js new file mode 100644 index 0000000..bde305e --- /dev/null +++ b/backend/src/routes/leaderboard.js @@ -0,0 +1,124 @@ +import express from 'express'; +import { query } from '../db/index.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = express.Router(); + +// Get leaderboard for a specific challenge +router.get('/challenge/:challengeId', authMiddleware, async (req, res) => { + try { + const { challengeId } = req.params; + + // Verify access + const access = await query( + `SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN ( + SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted' + ))`, + [challengeId, req.user.userId, req.user.userId] + ); + + if (access.length === 0) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Get leaderboard + const leaderboard = await query( + `SELECT + u.id, + u.username, + COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as validated_points, + COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions, + COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions + FROM challenge_participants cp + INNER JOIN users u ON cp.user_id = u.id + LEFT JOIN predictions p ON p.user_id = u.id AND p.challenge_id = ? + WHERE cp.challenge_id = ? AND cp.status = 'accepted' + GROUP BY u.id, u.username + + UNION + + SELECT + u.id, + u.username, + COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as validated_points, + COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions, + COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions + FROM challenges c + INNER JOIN users u ON c.created_by = u.id + LEFT JOIN predictions p ON p.user_id = u.id AND p.challenge_id = ? + WHERE c.id = ? + GROUP BY u.id, u.username + + ORDER BY validated_points DESC, username`, + [challengeId, challengeId, challengeId, challengeId] + ); + + res.json({ leaderboard }); + } catch (error) { + console.error('Challenge leaderboard error:', error); + res.status(500).json({ error: 'Failed to fetch leaderboard' }); + } +}); + +// Get global leaderboard (all users) +router.get('/global', authMiddleware, async (req, res) => { + try { + const leaderboard = await query( + `SELECT + u.id, + u.username, + COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as total_points, + COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions, + COUNT(DISTINCT p.challenge_id) as challenges_participated + FROM users u + LEFT JOIN predictions p ON p.user_id = u.id + GROUP BY u.id, u.username + HAVING total_points > 0 OR pending_predictions > 0 + ORDER BY total_points DESC, username + LIMIT 100` + ); + + res.json({ leaderboard }); + } catch (error) { + console.error('Global leaderboard error:', error); + res.status(500).json({ error: 'Failed to fetch leaderboard' }); + } +}); + +// Get user profile stats +router.get('/profile/:userId?', authMiddleware, async (req, res) => { + try { + const userId = req.params.userId || req.user.userId; + + const stats = await query( + `SELECT + u.id, + u.username, + u.email, + u.created_at, + COUNT(DISTINCT c.id) as challenges_created, + COUNT(DISTINCT cp.challenge_id) as challenges_joined, + COUNT(CASE WHEN p.status = 'validated' THEN 1 END) as total_points, + COUNT(CASE WHEN p.status = 'pending' THEN 1 END) as pending_predictions, + COUNT(CASE WHEN p.status = 'invalidated' THEN 1 END) as invalidated_predictions + FROM users u + LEFT JOIN challenges c ON c.created_by = u.id + LEFT JOIN challenge_participants cp ON cp.user_id = u.id AND cp.status = 'accepted' + LEFT JOIN predictions p ON p.user_id = u.id + WHERE u.id = ? + GROUP BY u.id`, + [userId] + ); + + if (stats.length === 0) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ profile: stats[0] }); + } catch (error) { + console.error('Profile stats error:', error); + res.status(500).json({ error: 'Failed to fetch profile' }); + } +}); + +export default router; diff --git a/backend/src/routes/predictions.js b/backend/src/routes/predictions.js new file mode 100644 index 0000000..cbc1fa4 --- /dev/null +++ b/backend/src/routes/predictions.js @@ -0,0 +1,140 @@ +import express from 'express'; +import { query } from '../db/index.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = express.Router(); + +// Get all predictions for a challenge +router.get('/challenge/:challengeId', authMiddleware, async (req, res) => { + try { + const { challengeId } = req.params; + + // Verify access + const access = await query( + `SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN ( + SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted' + ))`, + [challengeId, req.user.userId, req.user.userId] + ); + + if (access.length === 0) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Get predictions + const predictions = await query( + `SELECT + p.*, + u.username, + v.username as validated_by_username + FROM predictions p + INNER JOIN users u ON p.user_id = u.id + LEFT JOIN users v ON p.validated_by = v.id + WHERE p.challenge_id = ? + ORDER BY p.created_at DESC`, + [challengeId] + ); + + res.json({ predictions }); + } catch (error) { + console.error('Get predictions error:', error); + res.status(500).json({ error: 'Failed to fetch predictions' }); + } +}); + +// Create a new prediction +router.post('/', authMiddleware, async (req, res) => { + try { + const { challenge_id, content } = req.body; + + if (!challenge_id || !content || !content.trim()) { + return res.status(400).json({ error: 'Challenge ID and content are required' }); + } + + // Verify access + const access = await query( + `SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN ( + SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted' + ))`, + [challenge_id, req.user.userId, req.user.userId] + ); + + if (access.length === 0) { + return res.status(403).json({ error: 'Access denied' }); + } + + const result = await query( + 'INSERT INTO predictions (challenge_id, user_id, content) VALUES (?, ?, ?)', + [challenge_id, req.user.userId, content.trim()] + ); + + const prediction = { + id: result.insertId, + challenge_id, + user_id: req.user.userId, + username: req.user.username, + content: content.trim(), + status: 'pending', + created_at: new Date() + }; + + res.json({ prediction }); + } catch (error) { + console.error('Create prediction error:', error); + res.status(500).json({ error: 'Failed to create prediction' }); + } +}); + +// Validate/invalidate a prediction (approve someone else's) +router.post('/:id/validate', authMiddleware, async (req, res) => { + try { + const predictionId = req.params.id; + const { status } = req.body; // 'validated' or 'invalidated' + + if (!['validated', 'invalidated'].includes(status)) { + return res.status(400).json({ error: 'Invalid status' }); + } + + // Get the prediction + const predictions = await query( + 'SELECT * FROM predictions WHERE id = ?', + [predictionId] + ); + + if (predictions.length === 0) { + return res.status(404).json({ error: 'Prediction not found' }); + } + + const prediction = predictions[0]; + + // Cannot validate own prediction + if (prediction.user_id === req.user.userId) { + return res.status(403).json({ error: 'Cannot validate your own prediction' }); + } + + // Verify access to the challenge + const access = await query( + `SELECT * FROM challenges WHERE id = ? AND (created_by = ? OR id IN ( + SELECT challenge_id FROM challenge_participants WHERE user_id = ? AND status = 'accepted' + ))`, + [prediction.challenge_id, req.user.userId, req.user.userId] + ); + + if (access.length === 0) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Update prediction + await query( + 'UPDATE predictions SET status = ?, validated_by = ?, validated_at = NOW() WHERE id = ?', + [status, req.user.userId, predictionId] + ); + + res.json({ status }); + } catch (error) { + console.error('Validate prediction error:', error); + res.status(500).json({ error: 'Failed to validate prediction' }); + } +}); + +export default router; diff --git a/backend/src/routes/tmdb.js b/backend/src/routes/tmdb.js new file mode 100644 index 0000000..b1b0867 --- /dev/null +++ b/backend/src/routes/tmdb.js @@ -0,0 +1,74 @@ +import express from 'express'; +import { query } from '../db/index.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router = express.Router(); + +// Search for shows/movies via TMDB with caching +router.get('/search', authMiddleware, async (req, res) => { + try { + const { q } = req.query; + + if (!q || q.trim().length < 2) { + return res.json({ results: [] }); + } + + const searchQuery = q.trim(); + + // Check cache first + const cached = await query( + 'SELECT response_data FROM tmdb_cache WHERE query = ? AND media_type = ? AND cached_at > DATE_SUB(NOW(), INTERVAL 7 DAY)', + [searchQuery, 'multi'] + ); + + if (cached.length > 0) { + return res.json(JSON.parse(cached[0].response_data)); + } + + // Fetch from TMDB + const apiKey = process.env.TMDB_API_KEY; + if (!apiKey) { + return res.status(500).json({ error: 'TMDB API key not configured' }); + } + + const fetch = (await import('node-fetch')).default; + const response = await fetch( + `https://api.themoviedb.org/3/search/multi?api_key=${apiKey}&query=${encodeURIComponent(searchQuery)}` + ); + + if (!response.ok) { + throw new Error('TMDB API error'); + } + + const data = await response.json(); + + // Filter to only movies and TV shows + const filtered = data.results + .filter(item => item.media_type === 'movie' || item.media_type === 'tv') + .map(item => ({ + id: item.id, + title: item.media_type === 'movie' ? item.title : item.name, + media_type: item.media_type, + poster_path: item.poster_path, + backdrop_path: item.backdrop_path, + release_date: item.release_date || item.first_air_date, + overview: item.overview + })) + .slice(0, 10); + + const result = { results: filtered }; + + // Cache the result + await query( + 'INSERT INTO tmdb_cache (query, media_type, response_data) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE response_data = ?, cached_at = NOW()', + [searchQuery, 'multi', JSON.stringify(result), JSON.stringify(result)] + ); + + res.json(result); + } catch (error) { + console.error('TMDB search error:', error); + res.status(500).json({ error: 'Search failed' }); + } +}); + +export default router; diff --git a/docker-compose.yml b/docker-compose.yml index f84c0c3..2af8f70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,42 +1,35 @@ version: '3.8' services: db: - image: postgres:15 + image: linuxserver/mariadb restart: unless-stopped environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: whats_the_point + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: whats_the_point + TZ: America/Toronto volumes: - - db_data:/var/lib/postgresql/data + - db_data:/config ports: - - "5432:5432" + - "3306:3306" - backend: - build: ./backend + web: + build: . restart: unless-stopped environment: PORT: 4000 - JWT_SECRET: your_jwt_secret - DATABASE_URL: postgres://postgres:postgres@db:5432/whats_the_point + JWT_SECRET: dev_jwt_secret_change_in_production + DB_HOST: db + DB_USER: root + DB_PASSWORD: rootpassword + DB_NAME: whats_the_point + TMDB_API_KEY: your_tmdb_api_key_here depends_on: - db ports: - "4000:4000" volumes: - ./backend:/app - - frontend: - build: ./frontend - restart: unless-stopped - environment: - VITE_API_URL: http://localhost:4000 - depends_on: - - backend - ports: - - "5173:5173" - volumes: - - ./frontend:/app + - /app/node_modules volumes: db_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 342df35..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM node:20-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm install -COPY . . -EXPOSE 5173 -CMD ["npm", "run", "dev"] diff --git a/frontend/package.json b/frontend/package.json index a3cf751..b7ac290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,9 +9,11 @@ }, "dependencies": { "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" }, "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", "vite": "^5.0.0" } } diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..e93285e --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,232 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --primary: #6366f1; + --primary-dark: #4f46e5; + --secondary: #8b5cf6; + --success: #10b981; + --danger: #ef4444; + --warning: #f59e0b; + --bg: #0f172a; + --bg-light: #1e293b; + --bg-lighter: #334155; + --text: #f1f5f9; + --text-muted: #94a3b8; + --border: #334155; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--bg); + color: var(--text); + line-height: 1.6; +} + +#root { + min-height: 100vh; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 0.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); +} + +.btn-success { + background: var(--success); + color: white; +} + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-secondary { + background: var(--bg-lighter); + color: var(--text); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.input, .textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--bg-light); + color: var(--text); + font-size: 1rem; + font-family: inherit; +} + +.input:focus, .textarea:focus { + outline: none; + border-color: var(--primary); +} + +.textarea { + resize: vertical; + min-height: 100px; +} + +.card { + background: var(--bg-light); + border-radius: 0.75rem; + padding: 1.5rem; + border: 1px solid var(--border); +} + +.header { + background: var(--bg-light); + border-bottom: 1px solid var(--border); + padding: 1rem 0; + position: sticky; + top: 0; + z-index: 100; +} + +.nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.nav-links { + display: flex; + gap: 1rem; + list-style: none; + flex-wrap: wrap; +} + +.nav-links a { + color: var(--text); + text-decoration: none; + padding: 0.5rem 1rem; + border-radius: 0.375rem; + transition: background 0.2s; +} + +.nav-links a:hover, .nav-links a.active { + background: var(--bg-lighter); +} + +.grid { + display: grid; + gap: 1rem; +} + +.grid-2 { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text-muted); +} + +.error { + color: var(--danger); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.success { + color: var(--success); + font-size: 0.875rem; + margin-top: 0.5rem; +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + color: var(--text-muted); +} + +@media (max-width: 768px) { + .nav { + flex-direction: column; + align-items: stretch; + } + + .nav-links { + gap: 0.25rem; + justify-content: center; + } + + .nav-links a { + padding: 0.5rem; + font-size: 0.875rem; + } + + .card { + padding: 1rem; + } + + .grid-2 { + grid-template-columns: 1fr; + } + + .btn { + width: 100%; + } +} + +@media (max-width: 1024px) { + [style*="grid-template-columns: minmax(0, 1fr) 300px"] { + grid-template-columns: 1fr !important; + } +} diff --git a/frontend/src/AuthContext.jsx b/frontend/src/AuthContext.jsx new file mode 100644 index 0000000..3e55ac1 --- /dev/null +++ b/frontend/src/AuthContext.jsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; +import api from './api'; + +const AuthContext = createContext(null); + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) { + api.getMe() + .then(data => setUser(data.user)) + .catch(() => { + localStorage.removeItem('token'); + api.setToken(null); + }) + .finally(() => setLoading(false)); + } else { + setLoading(false); + } + }, []); + + const login = async (email, password) => { + const data = await api.login(email, password); + setUser(data.user); + return data; + }; + + const register = async (email, username, password) => { + const data = await api.register(email, username, password); + setUser(data.user); + return data; + }; + + const logout = () => { + localStorage.removeItem('token'); + api.setToken(null); + setUser(null); + }; + + return ( + + {children} + + ); +}; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + return context; +}; diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..0a74360 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,158 @@ +const API_URL = import.meta.env.VITE_API_URL || '/api'; + +class API { + constructor() { + this.token = localStorage.getItem('token'); + } + + setToken(token) { + this.token = token; + if (token) { + localStorage.setItem('token', token); + } else { + localStorage.removeItem('token'); + } + } + + async request(endpoint, options = {}) { + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + + if (this.token) { + headers.Authorization = `Bearer ${this.token}`; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + ...options, + headers + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error(error.error || 'Request failed'); + } + + return response.json(); + } + + // Auth + async register(email, username, password) { + const data = await this.request('/auth/register', { + method: 'POST', + body: JSON.stringify({ email, username, password }) + }); + this.setToken(data.token); + return data; + } + + async login(email, password) { + const data = await this.request('/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }); + this.setToken(data.token); + return data; + } + + async getMe() { + return this.request('/auth/me'); + } + + // Challenges + async getChallenges() { + return this.request('/challenges'); + } + + async getChallenge(id) { + return this.request(`/challenges/${id}`); + } + + async createChallenge(data) { + return this.request('/challenges', { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async inviteToChallenge(challengeId, data) { + return this.request(`/challenges/${challengeId}/invite`, { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async respondToChallenge(challengeId, status) { + return this.request(`/challenges/${challengeId}/respond`, { + method: 'POST', + body: JSON.stringify({ status }) + }); + } + + // Predictions + async getPredictions(challengeId) { + return this.request(`/predictions/challenge/${challengeId}`); + } + + async createPrediction(data) { + return this.request('/predictions', { + method: 'POST', + body: JSON.stringify(data) + }); + } + + async validatePrediction(predictionId, status) { + return this.request(`/predictions/${predictionId}/validate`, { + method: 'POST', + body: JSON.stringify({ status }) + }); + } + + // Friends + async getFriends() { + return this.request('/friends'); + } + + async searchUsers(query) { + return this.request(`/friends/search?q=${encodeURIComponent(query)}`); + } + + async sendFriendRequest(userId) { + return this.request('/friends/request', { + method: 'POST', + body: JSON.stringify({ user_id: userId }) + }); + } + + async respondToFriendRequest(friendshipId, status) { + return this.request('/friends/respond', { + method: 'POST', + body: JSON.stringify({ friendship_id: friendshipId, status }) + }); + } + + async getFriendRequests() { + return this.request('/friends/requests'); + } + + // TMDB + async searchShows(query) { + return this.request(`/tmdb/search?q=${encodeURIComponent(query)}`); + } + + // Leaderboard + async getChallengeLeaderboard(challengeId) { + return this.request(`/leaderboard/challenge/${challengeId}`); + } + + async getGlobalLeaderboard() { + return this.request('/leaderboard/global'); + } + + async getProfile(userId) { + return this.request(`/leaderboard/profile${userId ? `/${userId}` : ''}`); + } +} + +export default new API(); diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 27e12b5..cd4d0af 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,9 +1,71 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom'; +import { AuthProvider, useAuth } from './AuthContext'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import ChallengeList from './pages/ChallengeList'; +import ChallengeDetail from './pages/ChallengeDetail'; +import Profile from './pages/Profile'; +import Friends from './pages/Friends'; +import Leaderboard from './pages/Leaderboard'; +import './App.css'; -const App = () =>
-

What's The Point

-

Welcome! The app is running.

-
; +function ProtectedRoute({ children }) { + const { user, loading } = useAuth(); + + if (loading) { + return
Loading...
; + } + + return user ? children : ; +} + +function Header() { + const { user, logout } = useAuth(); + + if (!user) return null; + + return ( +
+
+ +
+
+ ); +} + +function App() { + return ( + + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} ReactDOM.createRoot(document.getElementById('root')).render(); diff --git a/frontend/src/pages/ChallengeDetail.jsx b/frontend/src/pages/ChallengeDetail.jsx new file mode 100644 index 0000000..8244bbd --- /dev/null +++ b/frontend/src/pages/ChallengeDetail.jsx @@ -0,0 +1,290 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useAuth } from '../AuthContext'; +import api from '../api'; + +export default function ChallengeDetail() { + const { id } = useParams(); + const { user } = useAuth(); + const [challenge, setChallenge] = useState(null); + const [predictions, setPredictions] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [newPrediction, setNewPrediction] = useState(''); + const [inviteQuery, setInviteQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [showInvite, setShowInvite] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadChallenge(); + loadPredictions(); + loadLeaderboard(); + }, [id]); + + const loadChallenge = async () => { + try { + const data = await api.getChallenge(id); + setChallenge(data); + } catch (err) { + console.error('Failed to load challenge:', err); + } finally { + setLoading(false); + } + }; + + const loadPredictions = async () => { + try { + const data = await api.getPredictions(id); + setPredictions(data.predictions); + } catch (err) { + console.error('Failed to load predictions:', err); + } + }; + + const loadLeaderboard = async () => { + try { + const data = await api.getChallengeLeaderboard(id); + setLeaderboard(data.leaderboard); + } catch (err) { + console.error('Failed to load leaderboard:', err); + } + }; + + const handleCreatePrediction = async (e) => { + e.preventDefault(); + if (!newPrediction.trim()) return; + + try { + await api.createPrediction({ + challenge_id: id, + content: newPrediction + }); + setNewPrediction(''); + await loadPredictions(); + } catch (err) { + alert('Failed to create prediction: ' + err.message); + } + }; + + const handleValidate = async (predictionId, status) => { + try { + await api.validatePrediction(predictionId, status); + await loadPredictions(); + await loadLeaderboard(); + } catch (err) { + alert('Failed to validate: ' + err.message); + } + }; + + const handleSearchUsers = async (query) => { + setInviteQuery(query); + if (query.trim().length < 2) { + setSearchResults([]); + return; + } + + try { + const data = await api.searchUsers(query); + setSearchResults(data.users); + } catch (err) { + console.error('Search failed:', err); + } + }; + + const handleInvite = async (userId) => { + try { + await api.inviteToChallenge(id, { user_ids: [userId] }); + setInviteQuery(''); + setSearchResults([]); + alert('Invitation sent!'); + } catch (err) { + alert('Failed to send invite: ' + err.message); + } + }; + + if (loading) { + return
Loading challenge...
; + } + + if (!challenge) { + return
Challenge not found
; + } + + return ( +
+
+ {/* Header */} +
+ ← Back to Challenges +
+ +
+ {challenge.challenge.cover_image_url && ( + {challenge.challenge.title} + )} +
+

{challenge.challenge.title}

+

+ Created by {challenge.challenge.creator_username} +

+ +
+
+ + {/* Invite Section */} + {showInvite && ( +
+

Invite Someone

+ handleSearchUsers(e.target.value)} + /> + {searchResults.length > 0 && ( +
+ {searchResults.map(user => ( +
handleInvite(user.id)} + style={{ + padding: '1rem', + borderBottom: '1px solid var(--border)', + cursor: 'pointer' + }} + > +
{user.username}
+
{user.email}
+
+ ))} +
+ )} +
+ )} + +
+ {/* Main Content */} +
+ {/* New Prediction */} +
+

Make a Prediction

+
+