Compare commits

...

48 Commits

Author SHA1 Message Date
a0931df2f4 Merge branch 'main' into stage 2026-01-31 03:38:39 +00:00
33026a6ecd fix domain 2026-01-30 22:32:45 -05:00
352a23f008 cleanup visible bool false 2026-01-30 22:27:35 -05:00
b7b32b4fe6 adding admin options for pw reset 2026-01-30 22:15:31 -05:00
a725e7a0d1 Merge pull request 'stage' (#3) from stage into main
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 34s
Reviewed-on: #3
2026-01-31 03:11:04 +00:00
c2a6e1d41f Merge branch 'main' into stage 2026-01-31 03:10:58 +00:00
be91e1a078 move div for better presentation 2026-01-30 21:57:04 -05:00
a5b0d3352b add show/hide password functionality in Login and Register forms 2026-01-30 21:53:30 -05:00
bb3a2e0b65 fix button 2026-01-30 21:53:16 -05:00
854b7b76a3 Merge pull request 'test leaving challange invited to' (#2) from stage into main
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
Reviewed-on: #2
2026-01-31 00:49:10 +00:00
f47ea8efaa Merge branch 'main' into stage 2026-01-31 00:49:07 +00:00
75d6eb8bbc test leaving challange invited to 2026-01-30 19:45:22 -05:00
1070f9b3d2 Merge pull request 'stage' (#1) from stage into main
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
Reviewed-on: #1
2026-01-31 00:39:27 +00:00
94c9e28393 new button fix 2026-01-30 19:37:21 -05:00
ad6e686c74 button overlap 2026-01-30 19:27:09 -05:00
0a9acd8442 final touches
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 18:12:02 -05:00
88f7b0678b socket update
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 18:08:26 -05:00
fabf1d9de9 more friends
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
2026-01-30 18:04:06 -05:00
3aba9f28ff fix rejected friends
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
2026-01-30 18:01:34 -05:00
a2d5454780 consistency
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
2026-01-30 17:55:28 -05:00
480ad66376 more qol
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
2026-01-30 17:53:55 -05:00
7c674b07c6 friend QOL again
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:47:22 -05:00
dbd9f89fbd friend bugfix
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 34s
2026-01-30 17:43:02 -05:00
754badc60f more friends fixes
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 32s
2026-01-30 17:39:28 -05:00
8ad834d778 cleanup friend invite buttons
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:28:06 -05:00
78785aee42 friends query improvement
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:23:26 -05:00
ada42d08ce friend management QOL improvements
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:18:40 -05:00
b018664e83 fix friends
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:15:04 -05:00
790357c56a remove friend button
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:06:38 -05:00
d8e7de4107 fix for dev
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 17:03:14 -05:00
300a1387da ways to remove and delete
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 16:43:02 -05:00
45c98f9fc7 improve menu behavior
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 34s
2026-01-30 16:35:05 -05:00
9c0fd35804 fix env deploy
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 33s
2026-01-30 16:29:48 -05:00
7706234a7b new menu, trying to keep envs, faster api calls etc
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 18s
2026-01-30 16:17:26 -05:00
432dc48c9a fix FE/BE mix up
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 39s
2026-01-30 16:00:12 -05:00
b6527b97cd deploy fixes?
All checks were successful
Build Images and Deploy / Update-PROD-Stack (push) Successful in 36s
2026-01-30 15:47:42 -05:00
c83b16de3f test new deploy
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 19s
2026-01-30 15:38:31 -05:00
1081f921d4 lets go 2026-01-29 02:41:30 -05:00
172ac4a48d bugfix 2026-01-29 02:39:21 -05:00
6f971d1b50 more debug 2026-01-29 02:34:45 -05:00
1ee9b686a2 bugfix 2026-01-29 02:29:37 -05:00
f262abff16 logs 2026-01-29 02:24:55 -05:00
b567fda0ae bugfix 2026-01-29 02:20:56 -05:00
efa1ea3b45 live updates 2026-01-29 02:16:24 -05:00
864cbaece9 bugfix 2026-01-29 02:00:55 -05:00
3e3f37a570 refactor 2026-01-29 01:49:52 -05:00
31c37d9bdd more sql tests 2026-01-29 01:36:27 -05:00
73df0fc764 sql updates 2026-01-29 01:31:55 -05:00
32 changed files with 2626 additions and 794 deletions

View File

@@ -12,7 +12,7 @@ on:
env:
STACK_NAME: wtp-prod
DOT_ENV: ${{ secrets.PROD_ENV }}
PORTAINER_TOKEN: ${{ secrets.PORTAINER_TOKEN }}
PORTAINER_TOKEN: ${{ vars.PORTAINER_TOKEN }}
PORTAINER_API_URL: https://portainer.dev.nervesocket.com/api
ENDPOINT_NAME: "mini" #sometimes "primary"
IMAGE_TAG: "reg.dev.nervesocket.com/wtp-prod:release"
@@ -33,8 +33,7 @@ jobs:
- name: Build and push PROD Docker image
run: |
echo $DOT_ENV | base64 -d > .env
echo .env
docker buildx build --push -t $IMAGE_TAG .
docker buildx build --push -f Dockerfile -t $IMAGE_TAG .
- name: Get the endpoint ID
# Usually ID is 1, but you can get it from the API. Only skip this if you are VERY sure.
@@ -50,14 +49,28 @@ jobs:
echo "STACK_ID=$STACK_ID" >> $GITHUB_ENV
echo "Got stack ID: $STACK_ID matched with Endpoint ID: $ENDPOINT_ID"
- name: Fetch Stack
run: |
# Get the stack details (including env vars)
STACK_DETAILS=$(curl -s -H "X-API-Key: $PORTAINER_TOKEN" "$PORTAINER_API_URL/stacks/$STACK_ID")
# Extract environment variables from the stack
echo "$STACK_DETAILS" | jq -r '.Env' > stack_env.json
echo "Existing stack environment variables:"
cat stack_env.json
- name: Redeploy stack in Portainer
run: |
# Read stack file content
STACK_FILE_CONTENT=$(echo "$(<prod-compose.yml )")
# Prepare JSON payload
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true \
'{stackFileContent: $stackFileContent, pullImage: $pullImage}')
# Read existing environment variables from the fetched stack
ENV_VARS=$(cat stack_env.json)
# Prepare JSON payload with environment variables
JSON_PAYLOAD=$(jq -n --arg stackFileContent "$STACK_FILE_CONTENT" --argjson pullImage true --argjson env "$ENV_VARS" \
'{stackFileContent: $stackFileContent, pullImage: $pullImage, env: $env}')
echo "About to push the following JSON payload:"
echo $JSON_PAYLOAD

View File

@@ -14,6 +14,8 @@
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"cors": "^2.8.5",
"node-fetch": "^3.3.2"
"node-fetch": "^3.3.2",
"express-rate-limit": "^7.1.5",
"socket.io": "^4.6.1"
}
}

60
backend/src/config.js Normal file
View File

@@ -0,0 +1,60 @@
/**
* Environment configuration and validation
*/
const requiredEnvVars = [
'JWT_SECRET',
'DB_HOST',
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
'TMDB_API_KEY'
];
export function validateConfig() {
const missing = [];
for (const varName of requiredEnvVars) {
if (!process.env[varName]) {
missing.push(varName);
}
}
if (missing.length > 0) {
console.error('❌ Missing required environment variables:');
missing.forEach(varName => {
console.error(` - ${varName}`);
});
console.error('\nPlease set these in your .env file or environment.');
process.exit(1);
}
// Validate JWT_SECRET is strong enough
if (process.env.JWT_SECRET.length < 32) {
console.error('❌ JWT_SECRET must be at least 32 characters long for security.');
process.exit(1);
}
console.log('✅ Environment configuration validated');
}
export const config = {
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: '7d'
},
db: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306', 10)
},
tmdb: {
apiKey: process.env.TMDB_API_KEY,
baseUrl: 'https://api.themoviedb.org/3'
},
server: {
port: parseInt(process.env.PORT || '3000', 10)
}
};

View File

@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
is_admin BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_username (username)
@@ -76,3 +77,17 @@ CREATE TABLE IF NOT EXISTS tmdb_cache (
UNIQUE KEY unique_query (query, media_type),
INDEX idx_query (query)
);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id)
);

View File

@@ -4,32 +4,71 @@ import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { createServer } from 'http';
import rateLimit from 'express-rate-limit';
import { initDB } from './db/index.js';
import { validateConfig, config } from './config.js';
import { errorHandler } from './middleware/errorHandler.js';
import { initializeSocket } from './sockets/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';
import adminRoutes from './routes/admin.js';
dotenv.config();
// Validate environment configuration
validateConfig();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const httpServer = createServer(app);
// Initialize Socket.io
initializeSocket(httpServer);
// Rate limiting
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window
message: { error: 'Too many authentication attempts, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
const apiLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 100, // 100 requests per window
message: { error: 'Too many requests, please try again later.' },
standardHeaders: true,
legacyHeaders: false,
});
const tmdbLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute
message: { error: 'Too many search requests, please slow down.' },
standardHeaders: true,
legacyHeaders: false,
});
// Middleware
app.use(cors());
app.use(express.json());
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/challenges', challengeRoutes);
app.use('/api/predictions', predictionRoutes);
app.use('/api/friends', friendRoutes);
app.use('/api/tmdb', tmdbRoutes);
app.use('/api/leaderboard', leaderboardRoutes);
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/challenges', apiLimiter, challengeRoutes);
app.use('/api/predictions', apiLimiter, predictionRoutes);
app.use('/api/friends', apiLimiter, friendRoutes);
app.use('/api/tmdb', tmdbLimiter, tmdbRoutes);
app.use('/api/leaderboard', apiLimiter, leaderboardRoutes);
app.use('/api/admin', apiLimiter, adminRoutes);
// Health check
app.get('/api/health', (req, res) => {
@@ -37,7 +76,7 @@ app.get('/api/health', (req, res) => {
});
// Serve static frontend files (for production)
const frontendPath = path.join(__dirname, '../../frontend/dist');
const frontendPath = path.join(__dirname, '../frontend/dist');
const frontendExists = fs.existsSync(frontendPath);
if (frontendExists) {
@@ -57,13 +96,17 @@ if (frontendExists) {
});
}
const PORT = process.env.PORT || 4000;
// Error handling middleware (must be last)
app.use(errorHandler);
const PORT = config.server.port;
// Initialize database and start server
initDB()
.then(() => {
app.listen(PORT, '0.0.0.0', () => {
httpServer.listen(PORT, '0.0.0.0', () => {
console.log(`✅ Server running on port ${PORT}`);
console.log(`🔌 Socket.io ready for real-time updates`);
});
})
.catch(err => {

View File

@@ -1,4 +1,6 @@
import jwt from 'jsonwebtoken';
import { query } from '../db/index.js';
import { asyncHandler } from './errorHandler.js';
export const authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
@@ -17,3 +19,35 @@ export const authMiddleware = (req, res, next) => {
return res.status(401).json({ error: 'Invalid token' });
}
};
// Enhanced version that fetches user data including admin status
export const verifyToken = asyncHandler(async (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);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Fetch full user data including admin status
const users = await query(
'SELECT id, email, username, is_admin FROM users WHERE id = ?',
[decoded.userId]
);
if (users.length === 0) {
return res.status(401).json({ error: 'User not found' });
}
req.user = {
userId: users[0].id,
email: users[0].email,
username: users[0].username,
is_admin: users[0].is_admin
};
next();
});

View File

@@ -0,0 +1,58 @@
/**
* Centralized error handling middleware
*/
export class AppError extends Error {
constructor(message, statusCode = 500) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
export function errorHandler(err, req, res, next) {
let { statusCode = 500, message } = err;
// Log error for debugging
if (process.env.NODE_ENV !== 'production') {
console.error('Error:', {
message: err.message,
stack: err.stack,
statusCode,
path: req.path,
method: req.method
});
} else {
console.error('Error:', err.message);
}
// Handle specific error types
if (err.code === 'ER_DUP_ENTRY') {
statusCode = 409;
message = 'A record with this information already exists';
}
if (err.name === 'JsonWebTokenError') {
statusCode = 401;
message = 'Invalid token';
}
if (err.name === 'TokenExpiredError') {
statusCode = 401;
message = 'Token expired';
}
// Send error response
res.status(statusCode).json({
error: message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack })
});
}
// Async handler wrapper to catch errors in async routes
export function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}

View File

@@ -0,0 +1,88 @@
import express from 'express';
import crypto from 'crypto';
import { query } from '../db/index.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { verifyToken } from '../middleware/auth.js';
const router = express.Router();
// Middleware to check if user is admin
const requireAdmin = asyncHandler(async (req, res, next) => {
if (!req.user || !req.user.is_admin) {
throw new AppError('Admin access required', 403);
}
next();
});
// Get all users (admin only)
router.get('/users', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
const users = await query(
'SELECT id, email, username, is_admin, created_at FROM users ORDER BY created_at DESC'
);
res.json({ users });
}));
// Generate password reset token (admin only)
router.post('/generate-reset-token', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
const { userId } = req.body;
if (!userId) {
throw new AppError('User ID required', 400);
}
// Verify user exists
const users = await query('SELECT id, username, email FROM users WHERE id = ?', [userId]);
if (users.length === 0) {
throw new AppError('User not found', 404);
}
const user = users[0];
// Generate secure token
const token = crypto.randomBytes(32).toString('hex');
// Token expires in 1 hour
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
// Store token
await query(
'INSERT INTO password_reset_tokens (user_id, token, created_by, expires_at) VALUES (?, ?, ?, ?)',
[userId, token, req.user.userId, expiresAt]
);
res.json({
success: true,
token,
user: {
id: user.id,
username: user.username,
email: user.email
},
expiresAt
});
}));
// Get reset token history (admin only)
router.get('/reset-tokens', verifyToken, requireAdmin, asyncHandler(async (req, res) => {
const tokens = await query(`
SELECT
prt.id,
prt.token,
prt.created_at,
prt.expires_at,
prt.used_at,
u.id as user_id,
u.username,
u.email,
admin.username as created_by_username
FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
JOIN users admin ON prt.created_by = admin.id
ORDER BY prt.created_at DESC
LIMIT 100
`);
res.json({ tokens });
}));
export default router;

View File

@@ -2,121 +2,160 @@ import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { query } from '../db/index.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
const router = express.Router();
// Register
router.post('/register', async (req, res) => {
try {
const { email, username, password } = req.body;
router.post('/register', asyncHandler(async (req, res) => {
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' });
if (!email || !username || !password) {
throw new AppError('All fields required', 400);
}
});
// Check if user exists
const existing = await query(
'SELECT id FROM users WHERE email = ? OR username = ?',
[email, username]
);
if (existing.length > 0) {
throw new AppError('User already exists', 400);
}
// 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 } });
}));
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
router.post('/login', asyncHandler(async (req, res) => {
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' });
if (!email || !password) {
throw new AppError('Email and password required', 400);
}
});
// Find user
const users = await query(
'SELECT id, email, username, password_hash FROM users WHERE email = ?',
[email]
);
if (users.length === 0) {
throw new AppError('Invalid credentials', 401);
}
const user = users[0];
// Check password
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
throw new AppError('Invalid credentials', 401);
}
// 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 }
});
}));
// 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' });
router.get('/me', asyncHandler(async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AppError('No token provided', 401);
}
});
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const users = await query(
'SELECT id, email, username, is_admin, created_at FROM users WHERE id = ?',
[decoded.userId]
);
if (users.length === 0) {
throw new AppError('User not found', 404);
}
res.json({ user: users[0] });
}));
// Reset password with token (public route)
router.post('/reset-password', asyncHandler(async (req, res) => {
const { token, newPassword } = req.body;
if (!token || !newPassword) {
throw new AppError('Token and new password required', 400);
}
if (newPassword.length < 6) {
throw new AppError('Password must be at least 6 characters', 400);
}
// Find valid token
const tokens = await query(
`SELECT prt.*, u.email, u.username
FROM password_reset_tokens prt
JOIN users u ON prt.user_id = u.id
WHERE prt.token = ? AND prt.used_at IS NULL AND prt.expires_at > NOW()`,
[token]
);
if (tokens.length === 0) {
throw new AppError('Invalid or expired reset token', 400);
}
const resetToken = tokens[0];
// Hash new password
const password_hash = await bcrypt.hash(newPassword, 10);
// Update password
await query(
'UPDATE users SET password_hash = ? WHERE id = ?',
[password_hash, resetToken.user_id]
);
// Mark token as used
await query(
'UPDATE password_reset_tokens SET used_at = NOW() WHERE id = ?',
[resetToken.id]
);
res.json({
success: true,
message: 'Password reset successfully',
user: {
email: resetToken.email,
username: resetToken.username
}
});
}));
export default router;

View File

@@ -1,63 +1,59 @@
import express from 'express';
import { query } from '../db/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { socketEvents } from '../sockets/index.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]
);
router.get('/', authMiddleware, asyncHandler(async (req, res) => {
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 IS NOT NULL
ORDER BY c.created_at DESC`,
[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' });
}
});
res.json({ challenges });
}));
// Get a single challenge with details
router.get('/:id', authMiddleware, async (req, res) => {
try {
const challengeId = req.params.id;
router.get('/:id', authMiddleware, asyncHandler(async (req, res) => {
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]
);
// 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' });
}
if (challenges.length === 0) {
throw new AppError('Challenge not found', 404);
}
const challenge = challenges[0];
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]
);
// 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' });
}
if (challenge.created_by !== req.user.userId && access.length === 0) {
throw new AppError('Access denied', 403);
}
// Get participants with their points
const participants = await query(
@@ -79,25 +75,20 @@ router.get('/:id', authMiddleware, async (req, res) => {
[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' });
}
});
res.json({
challenge,
participants,
creator_points: creatorPoints[0].points
});
}));
// Create a new challenge
router.post('/', authMiddleware, async (req, res) => {
try {
const { title, cover_image_url, tmdb_id, media_type } = req.body;
router.post('/', authMiddleware, asyncHandler(async (req, res) => {
const { title, cover_image_url, tmdb_id, media_type } = req.body;
if (!title) {
return res.status(400).json({ error: 'Title is required' });
}
if (!title) {
throw new AppError('Title is required', 400);
}
const result = await query(
'INSERT INTO challenges (title, cover_image_url, tmdb_id, media_type, created_by) VALUES (?, ?, ?, ?, ?)',
@@ -114,42 +105,37 @@ router.post('/', authMiddleware, async (req, res) => {
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' });
}
});
res.json({ 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;
router.post('/:id/invite', authMiddleware, asyncHandler(async (req, res) => {
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]
// Verify user owns the challenge or is a participant
const challenges = await query(
'SELECT * FROM challenges WHERE id = ?',
[challengeId]
);
if (challenges.length === 0) {
throw new AppError('Challenge not found', 404);
}
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 (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' });
}
if (participation.length === 0) {
throw new AppError('Only challenge participants can invite others', 403);
}
}
const invitedUsers = [];
@@ -162,57 +148,134 @@ router.post('/:id/invite', authMiddleware, async (req, res) => {
[challengeId, userId]
);
invitedUsers.push(userId);
// Emit real-time notification to invited user
socketEvents.challengeInvitation(userId, {
challenge_id: challengeId,
challenge_title: challenge.title,
invited_by: req.user.username
});
} 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);
// Emit real-time notification to invited user
socketEvents.challengeInvitation(users[0].id, {
challenge_id: challengeId,
challenge_title: challenge.title,
invited_by: req.user.username
});
} 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' });
}
});
res.json({ invited: invitedUsers.length });
}));
// 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'
router.post('/:id/respond', authMiddleware, asyncHandler(async (req, res) => {
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' });
if (!['accepted', 'rejected'].includes(status)) {
throw new AppError('Invalid status', 400);
}
});
await query(
'UPDATE challenge_participants SET status = ?, responded_at = NOW() WHERE challenge_id = ? AND user_id = ?',
[status, challengeId, req.user.userId]
);
// Emit real-time event to challenge participants
socketEvents.challengeInvitationResponse(challengeId, {
user_id: req.user.userId,
username: req.user.username,
status
});
res.json({ status });
}));
// Leave a challenge (participants only, not creator)
router.post('/:id/leave', authMiddleware, asyncHandler(async (req, res) => {
const challengeId = req.params.id;
// Get challenge to verify it exists and user is not creator
const challenges = await query(
'SELECT * FROM challenges WHERE id = ?',
[challengeId]
);
if (challenges.length === 0) {
throw new AppError('Challenge not found', 404);
}
const challenge = challenges[0];
if (challenge.created_by === req.user.userId) {
throw new AppError('Challenge creator cannot leave. Delete the challenge instead.', 403);
}
// Check if user is a participant
const participation = await query(
'SELECT * FROM challenge_participants WHERE challenge_id = ? AND user_id = ?',
[challengeId, req.user.userId]
);
if (participation.length === 0) {
throw new AppError('You are not a participant of this challenge', 404);
}
// Delete user's participation and predictions
await query('DELETE FROM predictions WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
await query('DELETE FROM challenge_participants WHERE challenge_id = ? AND user_id = ?', [challengeId, req.user.userId]);
res.json({ success: true, message: 'Left challenge successfully' });
}));
// Delete a challenge (only creator can delete)
router.delete('/:id', authMiddleware, asyncHandler(async (req, res) => {
const challengeId = req.params.id;
// Get challenge and verify ownership
const challenges = await query(
'SELECT * FROM challenges WHERE id = ?',
[challengeId]
);
if (challenges.length === 0) {
throw new AppError('Challenge not found', 404);
}
const challenge = challenges[0];
if (challenge.created_by !== req.user.userId) {
throw new AppError('Only the creator can delete this challenge', 403);
}
// Delete related data (cascade should handle this, but being explicit)
await query('DELETE FROM predictions WHERE challenge_id = ?', [challengeId]);
await query('DELETE FROM challenge_participants WHERE challenge_id = ?', [challengeId]);
await query('DELETE FROM challenges WHERE id = ?', [challengeId]);
res.json({ success: true, message: 'Challenge deleted successfully' });
}));
export default router;

View File

@@ -1,169 +1,216 @@
import express from 'express';
import { query } from '../db/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { socketEvents } from '../sockets/index.js';
const router = express.Router();
// Search for users by username or email
router.get('/search', authMiddleware, async (req, res) => {
try {
const { q } = req.query;
router.get('/search', authMiddleware, asyncHandler(async (req, res) => {
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' });
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 != ?
AND id NOT IN (
SELECT friend_id FROM friendships WHERE user_id = ? AND status IN ('accepted', 'pending')
UNION
SELECT user_id FROM friendships WHERE friend_id = ? AND status IN ('accepted', 'pending')
)
LIMIT 20`,
[searchTerm, searchTerm, req.user.userId, req.user.userId, req.user.userId]
);
res.json({ users });
}));
// 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]
);
router.get('/', authMiddleware, asyncHandler(async (req, res) => {
// 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)]
);
// Also get people who have shared challenges with me (auto-friends from challenges)
// Build the NOT IN clause only if there are friends to exclude
const notInClause = friends.length > 0
? `AND u.id NOT IN (${friends.map(() => '?').join(',')})`
: '';
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,
(SELECT status FROM friendships WHERE (user_id = ? AND friend_id = u.id) OR (user_id = u.id AND friend_id = ?) LIMIT 1) as friendship_status
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'
UNION
SELECT DISTINCT cp.user_id
FROM challenge_participants cp
INNER JOIN challenges c ON cp.challenge_id = c.id
WHERE c.created_by = ? AND cp.user_id != ? AND cp.status = 'accepted'
)
${notInClause}
ORDER BY u.username`,
[req.user.userId, req.user.userId, req.user.userId, req.user.userId, 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' });
}
});
res.json({
friends,
challenge_friends: challengeFriends
});
}));
// Send friend request
router.post('/request', authMiddleware, async (req, res) => {
try {
const { user_id } = req.body;
router.post('/request', authMiddleware, asyncHandler(async (req, res) => {
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' });
if (!user_id) {
throw new AppError('User ID required', 400);
}
});
if (user_id === req.user.userId) {
throw new AppError('Cannot add yourself as friend', 400);
}
// Check if already friends or pending request exists
const existing = await query(
`SELECT * FROM friendships
WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)
AND status IN ('accepted', 'pending')`,
[req.user.userId, user_id, user_id, req.user.userId]
);
if (existing.length > 0) {
throw new AppError('Friend request already exists or you are already friends', 400);
}
// Delete any old rejected requests before creating a new one
await query(
`DELETE FROM friendships
WHERE ((user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?))
AND status = 'rejected'`,
[req.user.userId, user_id, user_id, req.user.userId]
);
await query(
'INSERT INTO friendships (user_id, friend_id, status) VALUES (?, ?, "pending")',
[req.user.userId, user_id]
);
// Emit real-time notification to the user receiving the friend request
socketEvents.friendRequest(user_id, {
from_user_id: req.user.userId,
from_username: req.user.username
});
res.json({ success: true });
}));
// Accept/reject friend request
router.post('/respond', authMiddleware, async (req, res) => {
try {
const { friendship_id, status } = req.body;
router.post('/respond', authMiddleware, asyncHandler(async (req, res) => {
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' });
if (!['accepted', 'rejected'].includes(status)) {
throw new AppError('Invalid status', 400);
}
});
// 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) {
throw new AppError('Friend request not found', 404);
}
await query(
'UPDATE friendships SET status = ? WHERE id = ?',
[status, friendship_id]
);
// Get the friendship to notify the requester
const friendship = friendships[0];
// Emit real-time notification to the user who sent the request
socketEvents.friendRequestResponse(friendship.user_id, {
friend_id: req.user.userId,
friend_username: req.user.username,
status
});
res.json({ status });
}));
// 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]
);
router.get('/requests', authMiddleware, asyncHandler(async (req, res) => {
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' });
res.json({ requests });
}));
// Remove a friend
router.delete('/:friendId', authMiddleware, asyncHandler(async (req, res) => {
const friendId = parseInt(req.params.friendId);
if (!friendId) {
throw new AppError('Friend ID required', 400);
}
});
// Delete friendship in both directions
const result = await query(
`DELETE FROM friendships
WHERE (user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)`,
[req.user.userId, friendId, friendId, req.user.userId]
);
if (result.affectedRows === 0) {
throw new AppError('Friendship not found', 404);
}
// Emit real-time notification to the removed friend
socketEvents.friendRemoved(friendId, {
removed_by_user_id: req.user.userId,
removed_by_username: req.user.username
});
res.json({ success: true, message: 'Friend removed successfully' });
}));
export default router;

View File

@@ -1,25 +1,25 @@
import express from 'express';
import { query } from '../db/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
const router = express.Router();
// Get leaderboard for a specific challenge
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
try {
const { challengeId } = req.params;
router.get('/challenge/:challengeId', authMiddleware, asyncHandler(async (req, res) => {
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]
);
// 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' });
}
if (access.length === 0) {
throw new AppError('Access denied', 403);
}
// Get leaderboard
const leaderboard = await query(
@@ -53,72 +53,58 @@ router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
[challengeId, challengeId, challengeId, challengeId]
);
res.json({ leaderboard });
} catch (error) {
console.error('Challenge leaderboard error:', error);
res.status(500).json({ error: 'Failed to fetch leaderboard' });
}
});
res.json({ 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`
);
router.get('/global', authMiddleware, asyncHandler(async (req, res) => {
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' });
}
});
res.json({ leaderboard });
}));
// Get user profile stats
router.get('/profile/:userId?', authMiddleware, async (req, res) => {
try {
const userId = req.params.userId || req.user.userId;
router.get('/profile/:userId?', authMiddleware, asyncHandler(async (req, res) => {
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]
);
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' });
if (stats.length === 0) {
throw new AppError('User not found', 404);
}
});
res.json({ profile: stats[0] });
}));
export default router;

View File

@@ -1,140 +1,143 @@
import express from 'express';
import { query } from '../db/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.js';
import { socketEvents } from '../sockets/index.js';
const router = express.Router();
// Get all predictions for a challenge
router.get('/challenge/:challengeId', authMiddleware, async (req, res) => {
try {
const { challengeId } = req.params;
router.get('/challenge/:challengeId', authMiddleware, asyncHandler(async (req, res) => {
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]
);
// 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' });
if (access.length === 0) {
throw new AppError('Access denied', 403);
}
});
// 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 });
}));
// Create a new prediction
router.post('/', authMiddleware, async (req, res) => {
try {
const { challenge_id, content } = req.body;
router.post('/', authMiddleware, asyncHandler(async (req, res) => {
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' });
if (!challenge_id || !content || !content.trim()) {
throw new AppError('Challenge ID and content are required', 400);
}
});
// 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) {
throw new AppError('Access denied', 403);
}
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()
};
// Emit real-time event to all users in the challenge
socketEvents.predictionCreated(challenge_id, prediction);
res.json({ 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'
router.post('/:id/validate', authMiddleware, asyncHandler(async (req, res) => {
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' });
if (!['validated', 'invalidated'].includes(status)) {
throw new AppError('Invalid status', 400);
}
});
// Get the prediction
const predictions = await query(
'SELECT * FROM predictions WHERE id = ?',
[predictionId]
);
if (predictions.length === 0) {
throw new AppError('Prediction not found', 404);
}
const prediction = predictions[0];
// Cannot validate own prediction
if (prediction.user_id === req.user.userId) {
throw new AppError('Cannot validate your own prediction', 403);
}
// 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) {
throw new AppError('Access denied', 403);
}
// Update prediction
await query(
'UPDATE predictions SET status = ?, validated_by = ?, validated_at = NOW() WHERE id = ?',
[status, req.user.userId, predictionId]
);
// Get updated prediction with usernames
const updatedPrediction = 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.id = ?`,
[predictionId]
);
// Emit real-time event to all users in the challenge
socketEvents.predictionValidated(prediction.challenge_id, updatedPrediction[0]);
res.json({ status });
}));
export default router;

View File

@@ -1,79 +1,72 @@
import express from 'express';
import { query } from '../db/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { asyncHandler, AppError } from '../middleware/errorHandler.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;
router.get('/search', authMiddleware, asyncHandler(async (req, res) => {
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) {
const errorText = await response.text();
console.error('TMDB API error:', response.status, errorText);
return res.status(500).json({
error: `TMDB API error: ${response.status}`,
details: errorText
});
}
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' });
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) {
throw new AppError('TMDB API key not configured', 500);
}
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) {
const errorText = await response.text();
console.error('TMDB API error:', response.status, errorText);
throw new AppError(`TMDB API error: ${response.status}`, 500);
}
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);
}));
export default router;

View File

@@ -0,0 +1,123 @@
/**
* Socket.io setup and event handlers
*/
import { Server } from 'socket.io';
import jwt from 'jsonwebtoken';
let io;
export function initializeSocket(server) {
io = new Server(server, {
cors: {
origin: process.env.CORS_ORIGIN || '*',
credentials: true
}
});
// Authentication middleware for socket connections
io.use((socket, next) => {
try {
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error('Authentication error: No token provided'));
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.userId = decoded.userId;
socket.username = decoded.username;
next();
} catch (err) {
next(new Error('Authentication error: Invalid token'));
}
});
io.on('connection', (socket) => {
console.log(`✅ Socket connected: ${socket.username} (${socket.userId})`);
// Join personal room for user-specific notifications
socket.join(`user:${socket.userId}`);
console.log(`📍 ${socket.username} joined personal room: user:${socket.userId}`);
// Handle joining challenge rooms
socket.on('join:challenge', (challengeId) => {
socket.join(`challenge:${challengeId}`);
console.log(`👥 ${socket.username} joined challenge:${challengeId}`);
// Debug: Show all rooms this socket is in
console.log(` Current rooms:`, Array.from(socket.rooms));
});
// Handle leaving challenge rooms
socket.on('leave:challenge', (challengeId) => {
socket.leave(`challenge:${challengeId}`);
console.log(`👋 ${socket.username} left challenge:${challengeId}`);
});
socket.on('disconnect', () => {
console.log(`❌ Socket disconnected: ${socket.username}`);
});
});
return io;
}
export function getIO() {
if (!io) {
throw new Error('Socket.io not initialized');
}
return io;
}
// Event emitters for different actions
export const socketEvents = {
// Emit to specific user
emitToUser(userId, event, data) {
console.log(`🔔 Emitting ${event} to user:${userId}`);
io.to(`user:${userId}`).emit(event, data);
},
// Emit to all users in a challenge
emitToChallenge(challengeId, event, data) {
console.log(`🔔 Emitting ${event} to challenge:${challengeId}`);
io.to(`challenge:${challengeId}`).emit(event, data);
},
// Prediction events
predictionCreated(challengeId, prediction) {
console.log(`📤 Emitting prediction:created for challenge ${challengeId}`, prediction);
this.emitToChallenge(challengeId, 'prediction:created', prediction);
},
predictionValidated(challengeId, prediction) {
console.log(`📤 Emitting prediction:validated for challenge ${challengeId}`, prediction);
this.emitToChallenge(challengeId, 'prediction:validated', prediction);
},
// Challenge events
challengeInvitation(userId, challenge) {
this.emitToUser(userId, 'challenge:invitation', challenge);
},
challengeInvitationResponse(challengeId, response) {
this.emitToChallenge(challengeId, 'challenge:invitation_response', response);
},
// Friend events
friendRequest(userId, request) {
this.emitToUser(userId, 'friend:request', request);
},
friendRequestResponse(userId, response) {
this.emitToUser(userId, 'friend:response', response);
},
friendRemoved(userId, data) {
this.emitToUser(userId, 'friend:removed', data);
},
// Leaderboard updates
leaderboardUpdate(challengeId, leaderboard) {
this.emitToChallenge(challengeId, 'leaderboard:update', leaderboard);
}
};

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
db:
image: mariadb:10.11

View File

@@ -10,7 +10,9 @@
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
"react-router-dom": "^6.20.0",
"react-hot-toast": "^2.4.1",
"socket.io-client": "^4.6.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",

View File

@@ -134,14 +134,56 @@ body {
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.nav-brand {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.nav-brand h2 {
margin: 0;
}
.hamburger {
display: none;
flex-direction: column;
gap: 4px;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
z-index: 1001;
}
.hamburger span {
width: 25px;
height: 3px;
background: var(--text);
border-radius: 3px;
transition: all 0.3s ease;
}
.nav-backdrop {
display: none;
}
.nav-menu {
display: flex;
align-items: center;
gap: 2rem;
width: 100%;
justify-content: space-between;
}
.nav-links {
display: flex;
gap: 1rem;
list-style: none;
flex-wrap: wrap;
margin: 0;
padding: 0;
}
.nav-links a {
@@ -150,12 +192,17 @@ body {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: background 0.2s;
white-space: nowrap;
}
.nav-links a:hover, .nav-links a.active {
background: var(--bg-lighter);
}
.logout-btn {
white-space: nowrap;
}
.grid {
display: grid;
gap: 1rem;
@@ -207,19 +254,93 @@ body {
}
@media (max-width: 768px) {
.nav {
.nav-brand {
width: 100%;
}
.hamburger {
display: flex;
}
.nav-backdrop {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hamburger.active span:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger.active span:nth-child(2) {
opacity: 0;
}
.hamburger.active span:nth-child(3) {
transform: rotate(-45deg) translate(7px, -7px);
}
.nav-menu {
position: fixed;
top: 0;
right: -100%;
width: 70%;
height: 100vh;
background: var(--bg-light);
border-left: 1px solid var(--border);
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding: 5rem 2rem 2rem;
gap: 2rem;
transition: right 0.3s ease;
z-index: 1000;
}
.nav-menu.active {
right: 0;
}
.nav-links {
gap: 0.25rem;
justify-content: center;
flex-direction: column;
width: 100%;
gap: 0;
}
.nav-links li {
width: 100%;
}
.nav-links a {
padding: 0.5rem;
font-size: 0.875rem;
display: block;
padding: 1rem;
font-size: 1.125rem;
width: 100%;
border-bottom: 1px solid var(--border);
border-radius: 0;
}
.nav-links a:hover {
background: var(--bg-lighter);
border-radius: 0;
}
.logout-btn {
width: 100%;
}
.card {

View File

@@ -6,10 +6,12 @@ const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(localStorage.getItem('token'));
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
api.getMe()
.then(data => setUser(data.user))
.catch(() => {
@@ -25,12 +27,14 @@ export const AuthProvider = ({ children }) => {
const login = async (email, password) => {
const data = await api.login(email, password);
setUser(data.user);
setToken(data.token);
return data;
};
const register = async (email, username, password) => {
const data = await api.register(email, username, password);
setUser(data.user);
setToken(data.token);
return data;
};
@@ -38,10 +42,11 @@ export const AuthProvider = ({ children }) => {
localStorage.removeItem('token');
api.setToken(null);
setUser(null);
setToken(null);
};
return (
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
<AuthContext.Provider value={{ user, token, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
);

View File

@@ -0,0 +1,104 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { io } from 'socket.io-client';
import { useAuth } from './AuthContext';
const SocketContext = createContext();
export function useSocket() {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocket must be used within SocketProvider');
}
return context;
}
export function SocketProvider({ children }) {
const [socket, setSocket] = useState(null);
const [connected, setConnected] = useState(false);
const { user, token } = useAuth();
useEffect(() => {
// Only connect if user is authenticated
if (!user || !token) {
console.log('⏳ Waiting for authentication...', { user: !!user, token: !!token });
if (socket) {
socket.disconnect();
setSocket(null);
setConnected(false);
}
return;
}
console.log('🔄 Initializing socket connection...');
// Determine backend URL (strip /api if present since socket.io is at root)
let backendUrl = import.meta.env.VITE_API_URL ||
(import.meta.env.DEV ? 'http://localhost:4000' : window.location.origin);
// Remove /api suffix if present
backendUrl = backendUrl.replace(/\/api$/, '');
console.log('🌐 Connecting to:', backendUrl);
// Create socket connection with JWT authentication
const newSocket = io(backendUrl, {
auth: {
token
},
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
});
newSocket.on('connect', () => {
console.log('🔌 Socket connected');
setConnected(true);
});
newSocket.on('disconnect', () => {
console.log('❌ Socket disconnected');
setConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('❌ Socket connection error:', error.message);
console.error(' Error details:', error);
});
setSocket(newSocket);
// Cleanup on unmount
return () => {
newSocket.disconnect();
};
}, [user, token]);
const joinChallenge = (challengeId) => {
if (socket && connected) {
console.log(`📍 Joining challenge room: ${challengeId}`);
socket.emit('join:challenge', challengeId);
} else {
console.warn(`⚠️ Cannot join challenge ${challengeId} - socket:`, !!socket, 'connected:', connected);
}
};
const leaveChallenge = (challengeId) => {
if (socket && connected) {
console.log(`👋 Leaving challenge room: ${challengeId}`);
socket.emit('leave:challenge', challengeId);
}
};
const value = {
socket,
connected,
joinChallenge,
leaveChallenge
};
return (
<SocketContext.Provider value={value}>
{children}
</SocketContext.Provider>
);
}

View File

@@ -136,6 +136,24 @@ class API {
return this.request('/friends/requests');
}
async removeFriend(friendId) {
return this.request(`/friends/${friendId}`, {
method: 'DELETE'
});
}
async deleteChallenge(challengeId) {
return this.request(`/challenges/${challengeId}`, {
method: 'DELETE'
});
}
async leaveChallenge(challengeId) {
return this.request(`/challenges/${challengeId}/leave`, {
method: 'POST'
});
}
// TMDB
async searchShows(query) {
return this.request(`/tmdb/search?q=${encodeURIComponent(query)}`);
@@ -153,6 +171,29 @@ class API {
async getProfile(userId) {
return this.request(`/leaderboard/profile${userId ? `/${userId}` : ''}`);
}
// Admin
async getUsers() {
return this.request('/admin/users');
}
async generateResetToken(userId) {
return this.request('/admin/generate-reset-token', {
method: 'POST',
body: JSON.stringify({ userId })
});
}
async getResetTokens() {
return this.request('/admin/reset-tokens');
}
async resetPassword(token, newPassword) {
return this.request('/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword })
});
}
}
export default new API();

View File

@@ -0,0 +1,62 @@
import React from 'react';
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
}}>
<div className="card" style={{ maxWidth: '500px', textAlign: 'center' }}>
<h1 style={{ marginBottom: '1rem', color: 'var(--danger)' }}>Oops! Something went wrong</h1>
<p style={{ marginBottom: '1.5rem', color: 'var(--text-muted)' }}>
We're sorry, but something unexpected happened. Please refresh the page to try again.
</p>
<button
className="btn btn-primary"
onClick={() => window.location.href = '/'}
>
Go Home
</button>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{ marginTop: '2rem', textAlign: 'left' }}>
<summary style={{ cursor: 'pointer', color: 'var(--text-muted)' }}>Error Details</summary>
<pre style={{
marginTop: '1rem',
padding: '1rem',
background: 'var(--bg)',
borderRadius: '0.5rem',
overflow: 'auto',
fontSize: '0.75rem'
}}>
{this.state.error.toString()}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
export function useClickOutside(ref, handler) {
useEffect(() => {
const listener = (event) => {
if (!ref.current || ref.current.contains(event.target)) {
return;
}
handler(event);
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}

View File

@@ -1,7 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate, Link } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './AuthContext';
import { SocketProvider } from './SocketContext';
import Login from './pages/Login';
import Register from './pages/Register';
import ChallengeList from './pages/ChallengeList';
@@ -9,6 +11,9 @@ import ChallengeDetail from './pages/ChallengeDetail';
import Profile from './pages/Profile';
import Friends from './pages/Friends';
import Leaderboard from './pages/Leaderboard';
import Admin from './pages/Admin';
import PasswordReset from './pages/PasswordReset';
import ErrorBoundary from './components/ErrorBoundary';
import './App.css';
function ProtectedRoute({ children }) {
@@ -23,25 +28,54 @@ function ProtectedRoute({ children }) {
function Header() {
const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
if (!user) return null;
const handleLogout = () => {
setMobileMenuOpen(false);
logout();
};
const closeMobileMenu = () => setMobileMenuOpen(false);
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 className="nav-brand">
<Link to="/challenges" style={{ textDecoration: 'none', color: 'inherit' }}>
<h2>WTP</h2>
</Link>
<button
className={`hamburger ${mobileMenuOpen ? 'active' : ''}`}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
<span></span>
<span></span>
<span></span>
</button>
</div>
{mobileMenuOpen && (
<div
className="nav-backdrop"
onClick={closeMobileMenu}
aria-hidden="true"
/>
)}
<div className={`nav-menu ${mobileMenuOpen ? 'active' : ''}`}>
<ul className="nav-links">
<li><Link to="/challenges" onClick={closeMobileMenu}>Challenges</Link></li>
<li><Link to="/leaderboard" onClick={closeMobileMenu}>Leaderboard</Link></li>
<li><Link to="/friends" onClick={closeMobileMenu}>Friends</Link></li>
<li><Link to="/profile" onClick={closeMobileMenu}>Profile</Link></li>
{!!user.is_admin && <li><Link to="/admin" onClick={closeMobileMenu}>Admin</Link></li>}
</ul>
<button onClick={handleLogout} className="btn btn-secondary btn-sm logout-btn">
Logout
</button>
</div>
<button onClick={logout} className="btn btn-secondary btn-sm">
Logout
</button>
</nav>
</div>
</header>
@@ -50,21 +84,50 @@ function 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>
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<SocketProvider>
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#1e293b',
color: '#f1f5f9',
border: '1px solid #334155',
},
success: {
iconTheme: {
primary: '#10b981',
secondary: '#f1f5f9',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#f1f5f9',
},
},
}}
/>
<Header />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/reset-password/:token" element={<PasswordReset />} />
<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="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
<Route path="/" element={<Navigate to="/challenges" />} />
</Routes>
</SocketProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,301 @@
import { useState, useEffect } from 'react';
import { useAuth } from '../AuthContext';
import api from '../api';
import '../App.css';
function Admin() {
const { user } = useAuth();
const [users, setUsers] = useState([]);
const [resetTokens, setResetTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [generatedUrl, setGeneratedUrl] = useState(null);
const [activeTab, setActiveTab] = useState('users');
useEffect(() => {
if (!user?.is_admin) {
return;
}
loadData();
}, [user, activeTab]);
const loadData = async () => {
setLoading(true);
setError(null);
try {
if (activeTab === 'users') {
const data = await api.getUsers();
setUsers(data.users);
} else {
const data = await api.getResetTokens();
setResetTokens(data.tokens);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleGenerateResetToken = async (userId) => {
try {
const data = await api.generateResetToken(userId);
// Build URL using current domain (works in dev and prod)
const resetUrl = `${window.location.origin}/reset-password/${data.token}`;
setGeneratedUrl({
url: resetUrl,
user: data.user,
expiresAt: data.expiresAt
});
// Refresh the list if on tokens tab
if (activeTab === 'tokens') {
loadData();
}
} catch (err) {
setError(err.message);
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
alert('Copied to clipboard!');
};
if (!user?.is_admin) {
return (
<div className="container">
<div className="card">
<h1>Access Denied</h1>
<p>Admin privileges required.</p>
</div>
</div>
);
}
return (
<div className="container">
<div className="card">
<h1>Admin Panel</h1>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '2rem', borderBottom: '2px solid #333' }}>
<button
onClick={() => setActiveTab('users')}
style={{
padding: '0.75rem 1.5rem',
background: activeTab === 'users' ? '#007bff' : 'transparent',
color: activeTab === 'users' ? 'white' : '#999',
border: 'none',
borderBottom: activeTab === 'users' ? '3px solid #007bff' : 'none',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: activeTab === 'users' ? 'bold' : 'normal'
}}
>
Users
</button>
<button
onClick={() => setActiveTab('tokens')}
style={{
padding: '0.75rem 1.5rem',
background: activeTab === 'tokens' ? '#007bff' : 'transparent',
color: activeTab === 'tokens' ? 'white' : '#999',
border: 'none',
borderBottom: activeTab === 'tokens' ? '3px solid #007bff' : 'none',
cursor: 'pointer',
fontSize: '1rem',
fontWeight: activeTab === 'tokens' ? 'bold' : 'normal'
}}
>
Reset Tokens
</button>
</div>
{generatedUrl && (
<div style={{
padding: '1.5rem',
background: '#1a3a1a',
border: '2px solid #28a745',
borderRadius: '8px',
marginBottom: '2rem'
}}>
<h3 style={{ color: '#28a745', marginTop: 0 }}> Reset Link Generated</h3>
<p><strong>User:</strong> {generatedUrl.user.username} ({generatedUrl.user.email})</p>
<p><strong>Expires:</strong> {new Date(generatedUrl.expiresAt).toLocaleString()}</p>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginTop: '1rem' }}>
<input
type="text"
value={generatedUrl.url}
readOnly
style={{
flex: 1,
padding: '0.75rem',
background: '#222',
color: '#fff',
border: '1px solid #444',
borderRadius: '4px',
fontSize: '0.9rem'
}}
/>
<button
onClick={() => copyToClipboard(generatedUrl.url)}
style={{
padding: '0.75rem 1.5rem',
background: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
Copy
</button>
</div>
<button
onClick={() => setGeneratedUrl(null)}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
background: '#444',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Close
</button>
</div>
)}
{error && (
<div style={{
padding: '1rem',
background: '#3a1a1a',
border: '2px solid #dc3545',
borderRadius: '8px',
marginBottom: '1rem',
color: '#dc3545'
}}>
{error}
</div>
)}
{loading ? (
<p>Loading...</p>
) : activeTab === 'users' ? (
<div>
<h2>Users ({users.length})</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #444' }}>
<th style={{ padding: '1rem', textAlign: 'left' }}>ID</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Username</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Email</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Admin</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Created</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ borderBottom: '1px solid #333' }}>
<td style={{ padding: '1rem' }}>{u.id}</td>
<td style={{ padding: '1rem' }}>{u.username}</td>
<td style={{ padding: '1rem' }}>{u.email}</td>
<td style={{ padding: '1rem' }}>{u.is_admin ? '✓' : ''}</td>
<td style={{ padding: '1rem' }}>
{new Date(u.created_at).toLocaleDateString()}
</td>
<td style={{ padding: '1rem' }}>
<button
onClick={() => handleGenerateResetToken(u.id)}
style={{
padding: '0.5rem 1rem',
background: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.9rem'
}}
>
Generate Reset Link
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div>
<h2>Password Reset Tokens</h2>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #444' }}>
<th style={{ padding: '1rem', textAlign: 'left' }}>User</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Created By</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Created At</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Expires At</th>
<th style={{ padding: '1rem', textAlign: 'left' }}>Status</th>
</tr>
</thead>
<tbody>
{resetTokens.map(token => {
const now = new Date();
const expired = new Date(token.expires_at) < now;
const used = !!token.used_at;
let status = 'Active';
let statusColor = '#28a745';
if (used) {
status = 'Used';
statusColor = '#6c757d';
} else if (expired) {
status = 'Expired';
statusColor = '#dc3545';
}
return (
<tr key={token.id} style={{ borderBottom: '1px solid #333' }}>
<td style={{ padding: '1rem' }}>
{token.username}
<br />
<small style={{ color: '#999' }}>{token.email}</small>
</td>
<td style={{ padding: '1rem' }}>{token.created_by_username}</td>
<td style={{ padding: '1rem' }}>
{new Date(token.created_at).toLocaleString()}
</td>
<td style={{ padding: '1rem' }}>
{new Date(token.expires_at).toLocaleString()}
</td>
<td style={{ padding: '1rem' }}>
<span style={{
padding: '0.25rem 0.75rem',
background: statusColor,
borderRadius: '12px',
fontSize: '0.85rem',
fontWeight: 'bold'
}}>
{status}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}
export default Admin;

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import toast from 'react-hot-toast';
import { useAuth } from '../AuthContext';
import { useSocket } from '../SocketContext';
import api from '../api';
import { useClickOutside } from '../hooks/useClickOutside';
export default function ChallengeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const { socket, joinChallenge, leaveChallenge } = useSocket();
const [challenge, setChallenge] = useState(null);
const [predictions, setPredictions] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
@@ -15,14 +20,17 @@ export default function ChallengeDetail() {
const [showInvite, setShowInvite] = useState(false);
const [loading, setLoading] = useState(true);
const [searchTimeout, setSearchTimeout] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [validating, setValidating] = useState(null);
const [deleting, setDeleting] = useState(false);
const [friends, setFriends] = useState([]);
const [inviting, setInviting] = useState(null);
const [leaving, setLeaving] = useState(false);
const searchRef = useRef(null);
useEffect(() => {
loadChallenge();
loadPredictions();
loadLeaderboard();
}, [id]);
useClickOutside(searchRef, () => setSearchResults([]));
const loadChallenge = async () => {
const loadChallenge = useCallback(async () => {
try {
const data = await api.getChallenge(id);
setChallenge(data);
@@ -31,49 +39,143 @@ export default function ChallengeDetail() {
} finally {
setLoading(false);
}
};
}, [id]);
const loadPredictions = async () => {
const loadPredictions = useCallback(async () => {
try {
const data = await api.getPredictions(id);
setPredictions(data.predictions);
} catch (err) {
console.error('Failed to load predictions:', err);
}
};
}, [id]);
const loadLeaderboard = async () => {
const loadLeaderboard = useCallback(async () => {
try {
const data = await api.getChallengeLeaderboard(id);
setLeaderboard(data.leaderboard);
} catch (err) {
console.error('Failed to load leaderboard:', err);
}
};
}, [id]);
const loadFriends = useCallback(async () => {
try {
const data = await api.getFriends();
// Combine regular friends and challenge friends
const allFriends = [...(data.friends || []), ...(data.challenge_friends || [])];
setFriends(allFriends);
} catch (err) {
console.error('Failed to load friends:', err);
}
}, []);
useEffect(() => {
loadChallenge();
loadPredictions();
loadLeaderboard();
loadFriends();
}, [loadChallenge, loadPredictions, loadLeaderboard, loadFriends]);
// Join challenge room for real-time updates
useEffect(() => {
if (socket && id) {
joinChallenge(id);
return () => {
leaveChallenge(id);
};
}
}, [socket, id]);
// Listen for real-time prediction events
useEffect(() => {
if (!socket) return;
const handlePredictionCreated = (prediction) => {
console.log('📥 Received prediction:created event', prediction);
setPredictions(prev => {
// Avoid duplicates
if (prev.some(p => p.id === prediction.id)) {
return prev;
}
return [prediction, ...prev];
});
// Don't show toast for your own predictions
if (prediction.user_id !== user.id) {
toast.success(`New prediction from ${prediction.username}`);
}
};
const handlePredictionValidated = (prediction) => {
console.log('📥 Received prediction:validated event', prediction);
setPredictions(prev =>
prev.map(p => p.id === prediction.id ? prediction : p)
);
loadLeaderboard(); // Refresh leaderboard when points change
if (prediction.user_id === user.id) {
toast.success(
prediction.status === 'validated'
? '🎉 Your prediction was validated!'
: '❌ Your prediction was invalidated'
);
} else {
toast(`${prediction.username}'s prediction ${prediction.status}`);
}
};
const handleInvitationResponse = (response) => {
console.log('📥 Received invitation response', response);
if (response.status === 'accepted') {
toast.success(`${response.username} joined the challenge!`);
loadChallenge(); // Refresh participant list
}
};
socket.on('prediction:created', handlePredictionCreated);
socket.on('prediction:validated', handlePredictionValidated);
socket.on('challenge:invitation_response', handleInvitationResponse);
return () => {
socket.off('prediction:created', handlePredictionCreated);
socket.off('prediction:validated', handlePredictionValidated);
socket.off('challenge:invitation_response', handleInvitationResponse);
};
}, [socket, user.id, loadLeaderboard, loadChallenge]);
const handleCreatePrediction = async (e) => {
e.preventDefault();
if (!newPrediction.trim()) return;
setSubmitting(true);
try {
await api.createPrediction({
challenge_id: id,
content: newPrediction
});
toast.success('Prediction submitted!');
setNewPrediction('');
await loadPredictions();
} catch (err) {
alert('Failed to create prediction: ' + err.message);
toast.error('Failed to create prediction: ' + err.message);
} finally {
setSubmitting(false);
}
};
const handleValidate = async (predictionId, status) => {
setValidating(predictionId);
try {
await api.validatePrediction(predictionId, status);
toast.success(status === 'validated' ? 'Prediction validated!' : 'Prediction invalidated');
await loadPredictions();
await loadLeaderboard();
} catch (err) {
alert('Failed to validate: ' + err.message);
toast.error('Failed to validate: ' + err.message);
} finally {
setValidating(null);
}
};
@@ -90,7 +192,7 @@ export default function ChallengeDetail() {
return;
}
// Debounce search by 1 second
// Debounce search by 0.5 second
const timeout = setTimeout(async () => {
try {
const data = await api.searchUsers(query);
@@ -98,19 +200,55 @@ export default function ChallengeDetail() {
} catch (err) {
console.error('Search failed:', err);
}
}, 1000);
}, 500);
setSearchTimeout(timeout);
};
const handleInvite = async (userId) => {
const handleInvite = async (userId, userName) => {
setInviting(userId);
try {
await api.inviteToChallenge(id, { user_ids: [userId] });
toast.success(`Invitation sent to ${userName || 'user'}!`);
setInviteQuery('');
setSearchResults([]);
alert('Invitation sent!');
await loadChallenge(); // Refresh to update participant list
} catch (err) {
alert('Failed to send invite: ' + err.message);
toast.error('Failed to send invite: ' + err.message);
} finally {
setInviting(null);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this challenge? This action cannot be undone.')) {
return;
}
setDeleting(true);
try {
await api.deleteChallenge(id);
toast.success('Challenge deleted successfully');
navigate('/challenges');
} catch (err) {
toast.error('Failed to delete challenge: ' + err.message);
setDeleting(false);
}
};
const handleLeave = async () => {
if (!confirm('Are you sure you want to leave this challenge? Your predictions will be removed.')) {
return;
}
setLeaving(true);
try {
await api.leaveChallenge(id);
toast.success('Left challenge successfully');
navigate('/challenges');
} catch (err) {
toast.error('Failed to leave challenge: ' + err.message);
setLeaving(false);
}
};
@@ -143,32 +281,98 @@ export default function ChallengeDetail() {
<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 style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowInvite(!showInvite)}
>
{showInvite ? 'Cancel' : 'Invite Friends'}
</button>
{challenge.challenge.created_by === user.id ? (
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete Challenge'}
</button>
) : (
<button
className="btn btn-danger btn-sm"
onClick={handleLeave}
disabled={leaving}
>
{leaving ? 'Leaving...' : 'Leave Challenge'}
</button>
)}
</div>
</div>
</div>
{/* Invite Section */}
{showInvite && (
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
<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)}
/>
{/* Friends List */}
{friends.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}>
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Your Friends</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{friends.filter(friend => {
// Filter out friends who are already participants
const isParticipant = challenge.participants?.some(p => p.id === friend.id);
const isCreator = challenge.challenge.created_by === friend.id;
return !isParticipant && !isCreator;
}).map(friend => (
<div
key={friend.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
padding: '0.75rem',
border: '1px solid var(--border)',
borderRadius: '0.5rem'
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{friend.username}</div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{friend.email}</div>
</div>
<button
className="btn btn-primary btn-sm"
onClick={() => handleInvite(friend.id, friend.username)}
disabled={inviting === friend.id}
style={{ flexShrink: 0, width: 'auto' }}
>
{inviting === friend.id ? 'Inviting...' : 'Invite'}
</button>
</div>
))}
</div>
</div>
)}
{/* Search for others */}
<div>
<h4 style={{ fontSize: '0.875rem', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>Search for Others</h4>
<input
type="text"
className="input"
placeholder="Search by username or email..."
value={inviteQuery}
onChange={(e) => handleSearchUsers(e.target.value)}
/>
</div>
{searchResults.length > 0 && (
<div style={{
position: 'absolute',
top: '100%',
bottom: 0,
left: 0,
right: 0,
transform: 'translateY(100%)',
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
@@ -180,15 +384,27 @@ export default function ChallengeDetail() {
{searchResults.map(user => (
<div
key={user.id}
onClick={() => handleInvite(user.id)}
style={{
padding: '1rem',
borderBottom: '1px solid var(--border)',
cursor: 'pointer'
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem'
}}
>
<div style={{ fontWeight: 500 }}>{user.username}</div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<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={() => handleInvite(user.id, user.username)}
disabled={inviting === user.id}
style={{ flexShrink: 0, width: 'auto' }}
>
{inviting === user.id ? 'Inviting...' : 'Invite'}
</button>
</div>
))}
</div>
@@ -232,8 +448,8 @@ export default function ChallengeDetail() {
onChange={(e) => setNewPrediction(e.target.value)}
style={{ minHeight: '80px' }}
/>
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }}>
Submit Prediction
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Prediction'}
</button>
</form>
</div>
@@ -261,14 +477,16 @@ export default function ChallengeDetail() {
<button
className="btn btn-success btn-sm"
onClick={() => handleValidate(pred.id, 'validated')}
disabled={validating === pred.id}
>
Validate
{validating === pred.id ? '...' : '✓ Validate'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleValidate(pred.id, 'invalidated')}
disabled={validating === pred.id}
>
Invalidate
{validating === pred.id ? '...' : '✗ Invalidate'}
</button>
</>
)}

View File

@@ -1,6 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { Link } from 'react-router-dom';
import toast from 'react-hot-toast';
import api from '../api';
import { useClickOutside } from '../hooks/useClickOutside';
import { useSocket } from '../SocketContext';
export default function ChallengeList() {
const [challenges, setChallenges] = useState([]);
@@ -10,11 +13,32 @@ export default function ChallengeList() {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [searchTimeout, setSearchTimeout] = useState(null);
const [respondingTo, setRespondingTo] = useState(null);
const { socket } = useSocket();
const searchRef = useRef(null);
useClickOutside(searchRef, () => setShowResults([]));
useEffect(() => {
loadChallenges();
}, []);
// Listen for real-time challenge invitations
useEffect(() => {
if (!socket) return;
const handleChallengeInvitation = (invitation) => {
toast.success(`📬 ${invitation.invited_by} invited you to "${invitation.challenge_title}"`);
loadChallenges(); // Refresh to show new invitation
};
socket.on('challenge:invitation', handleChallengeInvitation);
return () => {
socket.off('challenge:invitation', handleChallengeInvitation);
};
}, [socket]);
const loadChallenges = async () => {
try {
const data = await api.getChallenges();
@@ -31,11 +55,15 @@ export default function ChallengeList() {
};
const handleRespondToInvite = async (challengeId, status) => {
setRespondingTo(challengeId);
try {
await api.respondToChallenge(challengeId, status);
toast.success(status === 'accepted' ? 'Challenge accepted!' : 'Challenge declined');
await loadChallenges();
} catch (err) {
alert('Failed to respond: ' + err.message);
toast.error('Failed to respond: ' + err.message);
} finally {
setRespondingTo(null);
}
};
@@ -52,7 +80,7 @@ export default function ChallengeList() {
return;
}
// Debounce search by 1.5 seconds
// Debounce search by 0.75 seconds
const timeout = setTimeout(async () => {
try {
const data = await api.searchShows(query);
@@ -60,7 +88,7 @@ export default function ChallengeList() {
} catch (err) {
console.error('Search failed:', err);
}
}, 1500);
}, 750);
setSearchTimeout(timeout);
};
@@ -72,18 +100,19 @@ export default function ChallengeList() {
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
: null;
const result = await api.createChallenge({
await api.createChallenge({
title: show.title,
cover_image_url: coverImage,
tmdb_id: show.id,
media_type: show.media_type
});
toast.success('Challenge created!');
setSearchQuery('');
setShowResults([]);
await loadChallenges();
} catch (err) {
alert('Failed to create challenge: ' + err.message);
toast.error('Failed to create challenge: ' + err.message);
} finally {
setCreating(false);
}
@@ -124,14 +153,16 @@ export default function ChallengeList() {
<button
className="btn btn-success btn-sm"
onClick={() => handleRespondToInvite(challenge.id, 'accepted')}
disabled={respondingTo === challenge.id}
>
Accept
{respondingTo === challenge.id ? '...' : 'Accept'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleRespondToInvite(challenge.id, 'rejected')}
disabled={respondingTo === challenge.id}
>
Decline
{respondingTo === challenge.id ? '...' : 'Decline'}
</button>
</div>
</div>
@@ -141,7 +172,7 @@ export default function ChallengeList() {
)}
{/* Search/Create */}
<div style={{ marginBottom: '2rem', position: 'relative' }}>
<div style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
<input
type="text"
className="input"
@@ -214,7 +245,7 @@ export default function ChallengeList() {
/>
)}
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '0.5rem' }}>{challenge.title}</h3>
<h3 style={{ marginBottom: '0.5rem', color: 'var(--primary)' }}>{challenge.title}</h3>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
Created by {challenge.creator_username}
</p>

View File

@@ -1,5 +1,8 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import api from '../api';
import { useClickOutside } from '../hooks/useClickOutside';
import { useSocket } from '../SocketContext';
export default function Friends() {
const [friends, setFriends] = useState([]);
@@ -9,11 +12,52 @@ export default function Friends() {
const [searchResults, setSearchResults] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTimeout, setSearchTimeout] = useState(null);
const [responding, setResponding] = useState(null);
const [sending, setSending] = useState(null);
const [removing, setRemoving] = useState(null);
const { socket } = useSocket();
const searchRef = useRef(null);
useClickOutside(searchRef, () => setSearchResults([]));
useEffect(() => {
loadData();
}, []);
// Listen for real-time friend request events
useEffect(() => {
if (!socket) return;
const handleFriendRequest = (request) => {
toast.success(`👋 ${request.from_username} sent you a friend request`);
loadData(); // Refresh to show new request
};
const handleFriendResponse = (response) => {
if (response.status === 'accepted') {
toast.success(`🎉 ${response.friend_username} accepted your friend request!`);
loadData(); // Refresh friends list
} else {
toast(`${response.friend_username} declined your friend request`);
}
};
const handleFriendRemoved = (data) => {
toast(`${data.removed_by_username} removed you from their friends`);
loadData(); // Refresh friends list
};
socket.on('friend:request', handleFriendRequest);
socket.on('friend:response', handleFriendResponse);
socket.on('friend:removed', handleFriendRemoved);
return () => {
socket.off('friend:request', handleFriendRequest);
socket.off('friend:response', handleFriendResponse);
socket.off('friend:removed', handleFriendRemoved);
};
}, [socket]);
const loadData = async () => {
try {
const [friendsData, requestsData] = await Promise.all([
@@ -43,7 +87,7 @@ export default function Friends() {
return;
}
// Debounce search by 1 second
// Debounce search by 0.5 second
const timeout = setTimeout(async () => {
try {
const data = await api.searchUsers(query);
@@ -51,28 +95,53 @@ export default function Friends() {
} catch (err) {
console.error('Search failed:', err);
}
}, 1000);
}, 500);
setSearchTimeout(timeout);
};
const handleSendRequest = async (userId) => {
setSending(userId);
try {
await api.sendFriendRequest(userId);
setSearchQuery('');
setSearchResults([]);
alert('Friend request sent!');
toast.success('Friend request sent!');
await loadData(); // Refresh the friends list to update friendship status
} catch (err) {
alert('Failed to send request: ' + err.message);
toast.error('Failed to send request: ' + err.message);
} finally {
setSending(null);
}
};
const handleRespond = async (requestId, status) => {
setResponding(requestId);
try {
await api.respondToFriendRequest(requestId, status);
toast.success(status === 'accepted' ? 'Friend request accepted!' : 'Friend request declined');
await loadData();
} catch (err) {
alert('Failed to respond: ' + err.message);
toast.error('Failed to respond: ' + err.message);
} finally {
setResponding(null);
}
};
const handleRemoveFriend = async (friendId, friendName) => {
if (!confirm(`Are you sure you want to remove ${friendName} from your friends?`)) {
return;
}
setRemoving(friendId);
try {
await api.removeFriend(friendId);
toast.success('Friend removed');
await loadData();
} catch (err) {
toast.error('Failed to remove friend: ' + err.message);
} finally {
setRemoving(null);
}
};
@@ -86,7 +155,7 @@ export default function Friends() {
<h1 style={{ marginBottom: '2rem' }}>Friends</h1>
{/* Search */}
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
<h3 style={{ marginBottom: '1rem' }}>Add Friends</h3>
<input
type="text"
@@ -117,18 +186,21 @@ export default function Friends() {
borderBottom: '1px solid var(--border)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
gap: '1rem'
}}
>
<div>
<div style={{ flex: 1, minWidth: 0 }}>
<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)}
disabled={sending === user.id}
style={{ flexShrink: 0, width: 'auto' }}
>
Add Friend
{sending === user.id ? 'Sending...' : 'Add Friend'}
</button>
</div>
))}
@@ -142,23 +214,25 @@ export default function Friends() {
<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 key={req.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<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' }}>
<div style={{ display: 'flex', gap: '0.5rem', flexShrink: 0 }}>
<button
className="btn btn-success btn-sm"
onClick={() => handleRespond(req.id, 'accepted')}
disabled={responding === req.id}
>
Accept
{responding === req.id ? '...' : 'Accept'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleRespond(req.id, 'rejected')}
disabled={responding === req.id}
>
Reject
{responding === req.id ? '...' : 'Reject'}
</button>
</div>
</div>
@@ -175,28 +249,55 @@ export default function Friends() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{friends.map(friend => (
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
<div>
<div key={friend.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<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 style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexShrink: 0 }}>
<button
className="btn btn-danger btn-sm"
onClick={() => handleRemoveFriend(friend.id, friend.username)}
disabled={removing === friend.id}
style={{ flexShrink: 0 }}
>
{removing === friend.id ? 'Removing...' : 'Remove'}
</button>
</div>
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
</div>
))}
{challengeFriends.length > 0 && (
<>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: '1rem', marginTop: '0.5rem' }}>
{friends.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>
)}
{friends.length === 0 && (
<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 key={friend.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<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 style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexShrink: 0, flexWrap: 'nowrap' }}>
<button
className={`btn btn-sm ${friend.friendship_status === 'pending' ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => handleSendRequest(friend.id)}
disabled={sending === friend.id || friend.friendship_status === 'pending'}
style={{ flexShrink: 0 }}
>
{sending === friend.id ? 'Sending...' : friend.friendship_status === 'pending' ? 'Pending' : 'Add Friend'}
</button>
</div>
<div style={{ color: 'var(--primary)', whiteSpace: 'nowrap' }}>{friend.total_points} points</div>
</div>
))}
</>

View File

@@ -1,25 +1,26 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import toast from 'react-hot-toast';
import { useAuth } from '../AuthContext';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
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);
toast.success('Welcome back!');
navigate('/challenges');
} catch (err) {
setError(err.message);
toast.error(err.message || 'Login failed');
} finally {
setLoading(false);
}
@@ -42,15 +43,35 @@ export default function Login() {
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<div style={{ position: 'relative' }}>
<input
type={showPassword ? 'text' : 'password'}
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
style={{ paddingRight: '2.5rem' }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute',
right: '0.5rem',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
color: 'var(--text-muted)',
fontSize: '0.875rem'
}}
>
{showPassword ? '👁' : '👁🗨'}
</button>
</div>
</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>

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../api';
import '../App.css';
function PasswordReset() {
const { token } = useParams();
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError(null);
if (newPassword.length < 6) {
setError('Password must be at least 6 characters');
return;
}
if (newPassword !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
try {
await api.resetPassword(token, newPassword);
setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 3000);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (success) {
return (
<div className="container">
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h1 style={{ color: '#28a745' }}> Password Reset Successful</h1>
<p>Your password has been updated successfully.</p>
<p>Redirecting to login page...</p>
</div>
</div>
);
}
return (
<div className="container">
<div className="card" style={{ maxWidth: '500px', margin: '2rem auto' }}>
<h1>Reset Your Password</h1>
<p style={{ color: '#999', marginBottom: '2rem' }}>
Enter your new password below.
</p>
{error && (
<div style={{
padding: '1rem',
background: '#3a1a1a',
border: '2px solid #dc3545',
borderRadius: '8px',
marginBottom: '1rem',
color: '#dc3545'
}}>
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
New Password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={6}
style={{
width: '100%',
padding: '0.75rem',
background: '#222',
color: '#fff',
border: '1px solid #444',
borderRadius: '4px',
fontSize: '1rem'
}}
placeholder="Enter new password"
/>
</div>
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
Confirm Password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
style={{
width: '100%',
padding: '0.75rem',
background: '#222',
color: '#fff',
border: '1px solid #444',
borderRadius: '4px',
fontSize: '1rem'
}}
placeholder="Confirm new password"
/>
</div>
<button
type="submit"
disabled={loading}
style={{
width: '100%',
padding: '1rem',
background: loading ? '#666' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '1rem',
fontWeight: 'bold'
}}
>
{loading ? 'Resetting Password...' : 'Reset Password'}
</button>
</form>
<div style={{ marginTop: '2rem', textAlign: 'center' }}>
<a
href="/login"
style={{ color: '#007bff', textDecoration: 'none' }}
>
Back to Login
</a>
</div>
</div>
</div>
);
}
export default PasswordReset;

View File

@@ -1,26 +1,27 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import toast from 'react-hot-toast';
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 [showPassword, setShowPassword] = useState(false);
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);
toast.success('Account created successfully!');
navigate('/challenges');
} catch (err) {
setError(err.message);
toast.error(err.message || 'Registration failed');
} finally {
setLoading(false);
}
@@ -53,16 +54,36 @@ export default function Register() {
</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 style={{ position: 'relative' }}>
<input
type={showPassword ? 'text' : 'password'}
className="input"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
style={{ paddingRight: '2.5rem' }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute',
right: '0.5rem',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
color: 'var(--text-muted)',
fontSize: '0.875rem'
}}
>
{showPassword ? '👁️' : '👁️‍🗨️'}
</button>
</div>
</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>

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
web:
image: reg.dev.nervesocket.com/wtp-prod:release