setup features
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -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"]
|
||||
125
README.md
125
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
|
||||
|
||||
132
SETUP_CHECKLIST.md
Normal file
132
SETUP_CHECKLIST.md
Normal file
@@ -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=<strong-random-secret>
|
||||
DB_HOST=db
|
||||
DB_USER=root
|
||||
DB_PASSWORD=<strong-database-password>
|
||||
DB_NAME=whats_the_point
|
||||
TMDB_API_KEY=<your-tmdb-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 <db-container-name>`
|
||||
- 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 <db-container> mysqldump -u root -p<password> whats_the_point > backup.sql
|
||||
|
||||
# Restore
|
||||
docker exec -i <db-container> mysql -u root -p<password> 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
|
||||
36
TMDB_SETUP.md
Normal file
36
TMDB_SETUP.md
Normal file
@@ -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.
|
||||
12
backend/.env.example
Normal file
12
backend/.env.example
Normal file
@@ -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
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 4000
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/src/db/index.js
Normal file
56
backend/src/db/index.js
Normal file
@@ -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;
|
||||
};
|
||||
78
backend/src/db/init.sql
Normal file
78
backend/src/db/init.sql
Normal file
@@ -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)
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
19
backend/src/middleware/auth.js
Normal file
19
backend/src/middleware/auth.js
Normal file
@@ -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' });
|
||||
}
|
||||
};
|
||||
122
backend/src/routes/auth.js
Normal file
122
backend/src/routes/auth.js
Normal file
@@ -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;
|
||||
218
backend/src/routes/challenges.js
Normal file
218
backend/src/routes/challenges.js
Normal file
@@ -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;
|
||||
169
backend/src/routes/friends.js
Normal file
169
backend/src/routes/friends.js
Normal file
@@ -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;
|
||||
124
backend/src/routes/leaderboard.js
Normal file
124
backend/src/routes/leaderboard.js
Normal file
@@ -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;
|
||||
140
backend/src/routes/predictions.js
Normal file
140
backend/src/routes/predictions.js
Normal file
@@ -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;
|
||||
74
backend/src/routes/tmdb.js
Normal file
74
backend/src/routes/tmdb.js
Normal file
@@ -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;
|
||||
@@ -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:
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
232
frontend/src/App.css
Normal file
232
frontend/src/App.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
56
frontend/src/AuthContext.jsx
Normal file
56
frontend/src/AuthContext.jsx
Normal file
@@ -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 (
|
||||
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
158
frontend/src/api.js
Normal file
158
frontend/src/api.js
Normal file
@@ -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();
|
||||
@@ -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 = () => <div style={{fontFamily: 'sans-serif', padding: 24}}>
|
||||
<h1>What's The Point</h1>
|
||||
<p>Welcome! The app is running.</p>
|
||||
</div>;
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return user ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="container">
|
||||
<nav className="nav">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2rem' }}>
|
||||
<h2 style={{ margin: 0 }}>WTP</h2>
|
||||
<ul className="nav-links">
|
||||
<li><Link to="/challenges">Challenges</Link></li>
|
||||
<li><Link to="/leaderboard">Leaderboard</Link></li>
|
||||
<li><Link to="/friends">Friends</Link></li>
|
||||
<li><Link to="/profile">Profile</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button onClick={logout} className="btn btn-secondary btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/challenges" element={<ProtectedRoute><ChallengeList /></ProtectedRoute>} />
|
||||
<Route path="/challenges/:id" element={<ProtectedRoute><ChallengeDetail /></ProtectedRoute>} />
|
||||
<Route path="/profile" element={<ProtectedRoute><Profile /></ProtectedRoute>} />
|
||||
<Route path="/friends" element={<ProtectedRoute><Friends /></ProtectedRoute>} />
|
||||
<Route path="/leaderboard" element={<ProtectedRoute><Leaderboard /></ProtectedRoute>} />
|
||||
<Route path="/" element={<Navigate to="/challenges" />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||
|
||||
290
frontend/src/pages/ChallengeDetail.jsx
Normal file
290
frontend/src/pages/ChallengeDetail.jsx
Normal file
@@ -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 <div className="loading">Loading challenge...</div>;
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
return <div className="loading">Challenge not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<Link to="/challenges" style={{ color: 'var(--primary)', textDecoration: 'none' }}>← Back to Challenges</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
|
||||
{challenge.challenge.cover_image_url && (
|
||||
<img
|
||||
src={challenge.challenge.cover_image_url}
|
||||
alt={challenge.challenge.title}
|
||||
style={{ width: '150px', height: '225px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ marginBottom: '0.5rem' }}>{challenge.challenge.title}</h1>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Created by {challenge.challenge.creator_username}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section */}
|
||||
{showInvite && (
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Invite Someone</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={inviteQuery}
|
||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleInvite(user.id)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '2rem', gridTemplateColumns: 'minmax(0, 1fr) 300px' }}>
|
||||
{/* Main Content */}
|
||||
<div>
|
||||
{/* New Prediction */}
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Make a Prediction</h3>
|
||||
<form onSubmit={handleCreatePrediction}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="I predict that..."
|
||||
value={newPrediction}
|
||||
onChange={(e) => setNewPrediction(e.target.value)}
|
||||
style={{ minHeight: '80px' }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }}>
|
||||
Submit Prediction
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Predictions Feed */}
|
||||
<h3 style={{ marginBottom: '1rem' }}>Predictions</h3>
|
||||
{predictions.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No predictions yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{predictions.map(pred => (
|
||||
<div key={pred.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<strong>{pred.username}</strong>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(pred.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ marginBottom: '1rem' }}>{pred.content}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{pred.status === 'pending' && pred.user_id !== user.id && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'validated')}
|
||||
>
|
||||
✓ Validate
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'invalidated')}
|
||||
>
|
||||
✗ Invalidate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pred.status === 'validated' && (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.875rem' }}>
|
||||
✓ Validated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'invalidated' && (
|
||||
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>
|
||||
✗ Invalidated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'pending' && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: '0.875rem' }}>
|
||||
⏳ Pending validation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Leaderboard */}
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
|
||||
{leaderboard.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{leaderboard.map((entry, index) => (
|
||||
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
|
||||
<strong>{entry.username}</strong>
|
||||
</div>
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
|
||||
{entry.validated_points} pts
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
frontend/src/pages/ChallengeList.jsx
Normal file
165
frontend/src/pages/ChallengeList.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
|
||||
export default function ChallengeList() {
|
||||
const [challenges, setChallenges] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenges();
|
||||
}, []);
|
||||
|
||||
const loadChallenges = async () => {
|
||||
try {
|
||||
const data = await api.getChallenges();
|
||||
setChallenges(data.challenges);
|
||||
} catch (err) {
|
||||
console.error('Failed to load challenges:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim().length < 2) {
|
||||
setShowResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.searchShows(query);
|
||||
setShowResults(data.results || []);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateChallenge = async (show) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
const coverImage = show.poster_path
|
||||
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
|
||||
: null;
|
||||
|
||||
const result = await api.createChallenge({
|
||||
title: show.title,
|
||||
cover_image_url: coverImage,
|
||||
tmdb_id: show.id,
|
||||
media_type: show.media_type
|
||||
});
|
||||
|
||||
setSearchQuery('');
|
||||
setShowResults([]);
|
||||
await loadChallenges();
|
||||
} catch (err) {
|
||||
alert('Failed to create challenge: ' + err.message);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading challenges...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>My Challenges</h1>
|
||||
|
||||
{/* Search/Create */}
|
||||
<div style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search for a show or movie to create a challenge..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{showResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg-light)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{showResults.map(show => (
|
||||
<div
|
||||
key={show.id}
|
||||
onClick={() => handleCreateChallenge(show)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{show.poster_path && (
|
||||
<img
|
||||
src={`https://image.tmdb.org/t/p/w92${show.poster_path}`}
|
||||
alt={show.title}
|
||||
style={{ width: '46px', height: '69px', objectFit: 'cover', borderRadius: '0.25rem' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{show.title}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{show.media_type === 'tv' ? 'TV Show' : 'Movie'} • {show.release_date?.substring(0, 4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Challenge List */}
|
||||
{challenges.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No challenges yet. Search for a show above to create one!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-2">
|
||||
{challenges.map(challenge => (
|
||||
<Link key={challenge.id} to={`/challenges/${challenge.id}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="card" style={{ height: '100%', display: 'flex', gap: '1rem' }}>
|
||||
{challenge.cover_image_url && (
|
||||
<img
|
||||
src={challenge.cover_image_url}
|
||||
alt={challenge.title}
|
||||
style={{ width: '80px', height: '120px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{challenge.title}</h3>
|
||||
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
Created by {challenge.creator_username}
|
||||
</p>
|
||||
<p style={{ marginTop: '0.5rem', color: 'var(--primary)' }}>
|
||||
{challenge.my_points} points
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/pages/Friends.jsx
Normal file
198
frontend/src/pages/Friends.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export default function Friends() {
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [challengeFriends, setChallengeFriends] = useState([]);
|
||||
const [requests, setRequests] = useState([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [friendsData, requestsData] = await Promise.all([
|
||||
api.getFriends(),
|
||||
api.getFriendRequests()
|
||||
]);
|
||||
setFriends(friendsData.friends);
|
||||
setChallengeFriends(friendsData.challenge_friends || []);
|
||||
setRequests(requestsData.requests);
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query) => {
|
||||
setSearchQuery(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 handleSendRequest = async (userId) => {
|
||||
try {
|
||||
await api.sendFriendRequest(userId);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Friend request sent!');
|
||||
} catch (err) {
|
||||
alert('Failed to send request: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async (requestId, status) => {
|
||||
try {
|
||||
await api.respondToFriendRequest(requestId, status);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
alert('Failed to respond: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Friends</h1>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Add Friends</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => handleSendRequest(user.id)}
|
||||
>
|
||||
Add Friend
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Requests */}
|
||||
{requests.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Friend Requests</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{requests.map(req => (
|
||||
<div key={req.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{req.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{req.email}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'accepted')}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleRespond(req.id, 'rejected')}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Friends List */}
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Your Friends</h3>
|
||||
{friends.length === 0 && challengeFriends.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)' }}>No friends yet. Search above to add some!</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{friends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
{challengeFriends.length > 0 && (
|
||||
<>
|
||||
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
|
||||
From Challenges
|
||||
</div>
|
||||
</div>
|
||||
{challengeFriends.map(friend => (
|
||||
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{friend.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
|
||||
</div>
|
||||
<div style={{ color: 'var(--primary)' }}>{friend.total_points} points</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/pages/Leaderboard.jsx
Normal file
89
frontend/src/pages/Leaderboard.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
export default function Leaderboard() {
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadLeaderboard();
|
||||
}, []);
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
try {
|
||||
const data = await api.getGlobalLeaderboard();
|
||||
setLeaderboard(data.leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading leaderboard...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Global Leaderboard</h1>
|
||||
|
||||
<div className="card">
|
||||
{leaderboard.length === 0 ? (
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '2rem' }}>
|
||||
No one has any points yet. Start making predictions!
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{leaderboard.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: index < 3 ? 'var(--bg-lighter)' : 'var(--bg-light)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: index === 0 ? '#fbbf24' : index === 1 ? '#94a3b8' : index === 2 ? '#cd7f32' : 'var(--bg)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '1.125rem'
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: '1.125rem' }}>{entry.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{entry.challenges_participated} challenge{entry.challenges_participated !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 'bold', color: 'var(--primary)' }}>
|
||||
{entry.total_points}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/pages/Login.jsx
Normal file
64
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>What's The Point</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: '1.5rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Don't have an account? <Link to="/register" style={{ color: 'var(--primary)' }}>Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
frontend/src/pages/Profile.jsx
Normal file
82
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
|
||||
export default function Profile() {
|
||||
const { user } = useAuth();
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
}, []);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await api.getProfile();
|
||||
setProfile(data.profile);
|
||||
} catch (err) {
|
||||
console.error('Failed to load profile:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading profile...</div>;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return <div className="loading">Profile not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
<h1 style={{ marginBottom: '2rem' }}>Profile</h1>
|
||||
|
||||
<div className="grid grid-2">
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>User Info</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Username</div>
|
||||
<div style={{ fontWeight: 500 }}>{profile.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Email</div>
|
||||
<div>{profile.email}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>Member Since</div>
|
||||
<div>{new Date(profile.created_at).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Stats</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Total Points</span>
|
||||
<strong style={{ color: 'var(--primary)', fontSize: '1.25rem' }}>{profile.total_points}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Pending Predictions</span>
|
||||
<strong>{profile.pending_predictions}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Challenges Created</span>
|
||||
<strong>{profile.challenges_created}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>Challenges Joined</span>
|
||||
<strong>{profile.challenges_joined}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Register.jsx
Normal file
76
frontend/src/pages/Register.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
|
||||
export default function Register() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await register(email, username, password);
|
||||
navigate('/challenges');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '1rem' }}>
|
||||
<div className="card" style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<h1 style={{ marginBottom: '2rem', textAlign: 'center' }}>Create Account</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button type="submit" className="btn btn-primary" style={{ width: '100%', marginTop: '1rem' }} disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
</form>
|
||||
<p style={{ marginTop: '1.5rem', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Already have an account? <Link to="/login" style={{ color: 'var(--primary)' }}>Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
frontend/vite.config.js
Normal file
13
frontend/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
watch: {
|
||||
usePolling: true
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
web:
|
||||
image: reg.dev.nervesocket.com/wtp-prod:release
|
||||
@@ -5,20 +6,26 @@ services:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- APACHE_LOG_DIR=/var/www/app
|
||||
- TZ=America/Toronto
|
||||
PORT: 80
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
DB_HOST: db
|
||||
DB_USER: root
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
DB_NAME: whats_the_point
|
||||
TMDB_API_KEY: ${TMDB_API_KEY}
|
||||
TZ: America/Toronto
|
||||
volumes:
|
||||
- /volume1/docker/wtp-prod/production_web:/app/public/storage
|
||||
ports:
|
||||
- 22798:80
|
||||
|
||||
db:
|
||||
# image: mariadb:10.7
|
||||
image: linuxserver/mariadb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=XLxXDnUvfkTbDzDlEP5Gy8It
|
||||
- TZ=America/Toronto
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||
MYSQL_DATABASE: whats_the_point
|
||||
TZ: America/Toronto
|
||||
volumes:
|
||||
- /volume1/docker/wtp-prod/db:/config
|
||||
|
||||
|
||||
Reference in New Issue
Block a user