setup features

This commit is contained in:
2026-01-29 00:24:10 -05:00
parent 787c97a52f
commit 4a6e2c307c
34 changed files with 2891 additions and 71 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

30
Dockerfile Normal file
View 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
View File

@@ -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
View 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
View 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
View 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

View File

@@ -1,7 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 4000
CMD ["npm", "run", "dev"]

View File

@@ -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
View 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
View 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)
);

View File

@@ -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);
});

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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:

View File

@@ -1,7 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

View File

@@ -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
View 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;
}
}

View 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
View 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();

View File

@@ -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 />);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
}
}
});

View File

@@ -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