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.
|
A web app for tracking predictions and points in TV/movie challenges with friends.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Register/login with email and password
|
- 🔐 Register/login with email and password (JWT authentication)
|
||||||
- Create and join challenges for shows/movies
|
- 🎬 Create challenges for shows/movies with TMDB integration
|
||||||
- Make and approve predictions
|
- 📝 Make and validate predictions with friends
|
||||||
- Mobile-first, modern UI
|
- 🏆 Leaderboards (per-challenge and global)
|
||||||
|
- 👥 Friend system for easy invitations
|
||||||
|
- 📱 Mobile-first, modern dark UI
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- Frontend: React (Vite)
|
- **Frontend:** React 18, React Router, Vite
|
||||||
- Backend: Node.js (Express)
|
- **Backend:** Node.js, Express
|
||||||
- Database: PostgreSQL
|
- **Database:** MariaDB
|
||||||
- Auth: JWT (email/password)
|
- **Auth:** JWT with bcrypt
|
||||||
- Dockerized, self-hosted
|
- **APIs:** The Movie Database (TMDB) with caching
|
||||||
|
- **Deployment:** Docker, self-hosted via Gitea + Portainer
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Docker & Docker Compose
|
- Docker & Docker Compose
|
||||||
|
- TMDB API Key (free from https://www.themoviedb.org/settings/api)
|
||||||
|
|
||||||
### Setup
|
### Local Development
|
||||||
1. Copy `.env.example` to `.env` and fill in secrets.
|
|
||||||
2. Build and start all services:
|
1. **Copy environment file:**
|
||||||
```sh
|
```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
|
docker compose up --build
|
||||||
```
|
```
|
||||||
3. Access the frontend at http://localhost:5173
|
|
||||||
4. API runs at http://localhost:4000
|
|
||||||
|
|
||||||
## Deployment
|
4. **Access the app:**
|
||||||
- See `prod-compose.yml` for production deployment.
|
- 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
|
## License
|
||||||
MIT
|
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",
|
"main": "src/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node src/index.js"
|
"dev": "node src/index.js",
|
||||||
|
"start": "node src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"pg": "^8.11.3",
|
"mysql2": "^3.6.5",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^16.4.5",
|
"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 express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import dotenv from 'dotenv';
|
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();
|
dotenv.config();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
// API Routes
|
||||||
res.json({ message: "What's The Point API" });
|
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;
|
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'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: linuxserver/mariadb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
MYSQL_ROOT_PASSWORD: rootpassword
|
||||||
POSTGRES_PASSWORD: postgres
|
MYSQL_DATABASE: whats_the_point
|
||||||
POSTGRES_DB: whats_the_point
|
TZ: America/Toronto
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- db_data:/config
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "3306:3306"
|
||||||
|
|
||||||
backend:
|
web:
|
||||||
build: ./backend
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
JWT_SECRET: your_jwt_secret
|
JWT_SECRET: dev_jwt_secret_change_in_production
|
||||||
DATABASE_URL: postgres://postgres:postgres@db:5432/whats_the_point
|
DB_HOST: db
|
||||||
|
DB_USER: root
|
||||||
|
DB_PASSWORD: rootpassword
|
||||||
|
DB_NAME: whats_the_point
|
||||||
|
TMDB_API_KEY: your_tmdb_api_key_here
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
- /app/node_modules
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
VITE_API_URL: http://localhost:4000
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
ports:
|
|
||||||
- "5173:5173"
|
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
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": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"vite": "^5.0.0"
|
"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 React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
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}}>
|
function ProtectedRoute({ children }) {
|
||||||
<h1>What's The Point</h1>
|
const { user, loading } = useAuth();
|
||||||
<p>Welcome! The app is running.</p>
|
|
||||||
</div>;
|
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 />);
|
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:
|
services:
|
||||||
web:
|
web:
|
||||||
image: reg.dev.nervesocket.com/wtp-prod:release
|
image: reg.dev.nervesocket.com/wtp-prod:release
|
||||||
@@ -5,20 +6,26 @@ services:
|
|||||||
- db
|
- db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- APACHE_LOG_DIR=/var/www/app
|
PORT: 80
|
||||||
- TZ=America/Toronto
|
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:
|
volumes:
|
||||||
- /volume1/docker/wtp-prod/production_web:/app/public/storage
|
- /volume1/docker/wtp-prod/production_web:/app/public/storage
|
||||||
ports:
|
ports:
|
||||||
- 22798:80
|
- 22798:80
|
||||||
|
|
||||||
db:
|
db:
|
||||||
# image: mariadb:10.7
|
|
||||||
image: linuxserver/mariadb
|
image: linuxserver/mariadb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=XLxXDnUvfkTbDzDlEP5Gy8It
|
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
|
||||||
- TZ=America/Toronto
|
MYSQL_DATABASE: whats_the_point
|
||||||
|
TZ: America/Toronto
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/wtp-prod/db:/config
|
- /volume1/docker/wtp-prod/db:/config
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user