Files
whats-the-point/frontend/src/pages/ChallengeDetail.jsx
2026-01-29 02:16:24 -05:00

397 lines
14 KiB
JavaScript

import React, { useState, useEffect, useRef } from 'react';
import { useParams, Link } 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 { user } = useAuth();
const { socket, joinChallenge, leaveChallenge } = useSocket();
const [challenge, setChallenge] = useState(null);
const [predictions, setPredictions] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
const [newPrediction, setNewPrediction] = useState('');
const [inviteQuery, setInviteQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [showInvite, setShowInvite] = useState(false);
const [loading, setLoading] = useState(true);
const [searchTimeout, setSearchTimeout] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [validating, setValidating] = useState(null);
const searchRef = useRef(null);
useClickOutside(searchRef, () => setSearchResults([]));
useEffect(() => {
loadChallenge();
loadPredictions();
loadLeaderboard();
}, [id]);
// 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) => {
setPredictions(prev => [prediction, ...prev]);
toast.success(`New prediction from ${prediction.username}`);
};
const handlePredictionValidated = (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'
);
}
};
const handleInvitationResponse = (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]);
const loadChallenge = async () => {
try {
const data = await api.getChallenge(id);
setChallenge(data);
} catch (err) {
console.error('Failed to load challenge:', err);
} finally {
setLoading(false);
}
};
const loadPredictions = async () => {
try {
const data = await api.getPredictions(id);
setPredictions(data.predictions);
} catch (err) {
console.error('Failed to load predictions:', err);
}
};
const loadLeaderboard = async () => {
try {
const data = await api.getChallengeLeaderboard(id);
setLeaderboard(data.leaderboard);
} catch (err) {
console.error('Failed to load leaderboard:', err);
}
};
const handleCreatePrediction = async (e) => {
e.preventDefault();
if (!newPrediction.trim()) return;
setSubmitting(true);
try {
await api.createPrediction({
challenge_id: id,
content: newPrediction
});
toast.success('Prediction submitted!');
setNewPrediction('');
await loadPredictions();
} catch (err) {
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) {
toast.error('Failed to validate: ' + err.message);
} finally {
setValidating(null);
}
};
const handleSearchUsers = (query) => {
setInviteQuery(query);
// Clear previous timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (query.trim().length < 2) {
setSearchResults([]);
return;
}
// Debounce search by 1 second
const timeout = setTimeout(async () => {
try {
const data = await api.searchUsers(query);
setSearchResults(data.users);
} catch (err) {
console.error('Search failed:', err);
}
}, 1000);
setSearchTimeout(timeout);
};
const handleInvite = async (userId) => {
try {
await api.inviteToChallenge(id, { user_ids: [userId] });
toast.success('Invitation sent!');
setInviteQuery('');
setSearchResults([]);
} catch (err) {
toast.error('Failed to send invite: ' + err.message);
}
};
if (loading) {
return <div className="loading">Loading challenge...</div>;
}
if (!challenge) {
return <div className="loading">Challenge not found</div>;
}
return (
<div style={{ padding: '2rem 0' }}>
<div className="container">
{/* Header */}
<div style={{ marginBottom: '2rem' }}>
<Link to="/challenges" style={{ color: 'var(--primary)', textDecoration: 'none' }}> Back to Challenges</Link>
</div>
<div style={{ display: 'flex', gap: '2rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
{challenge.challenge.cover_image_url && (
<img
src={challenge.challenge.cover_image_url}
alt={challenge.challenge.title}
style={{ width: '150px', height: '225px', objectFit: 'cover', borderRadius: '0.5rem' }}
/>
)}
<div style={{ flex: 1 }}>
<h1 style={{ marginBottom: '0.5rem' }}>{challenge.challenge.title}</h1>
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
Created by {challenge.challenge.creator_username}
</p>
<button
className="btn btn-secondary btn-sm"
onClick={() => setShowInvite(!showInvite)}
>
{showInvite ? 'Cancel' : 'Invite Friends'}
</button>
</div>
</div>
{/* Invite Section */}
{showInvite && (
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }} 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)}
/>
{searchResults.length > 0 && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: 'var(--bg)',
border: '1px solid var(--border)',
borderRadius: '0.5rem',
marginTop: '0.5rem',
maxHeight: '300px',
overflowY: 'auto',
zIndex: 10
}}>
{searchResults.map(user => (
<div
key={user.id}
onClick={() => handleInvite(user.id)}
style={{
padding: '1rem',
borderBottom: '1px solid var(--border)',
cursor: 'pointer'
}}
>
<div style={{ fontWeight: 500 }}>{user.username}</div>
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
</div>
))}
</div>
)}
</div>
)}
{/* Leaderboard - Desktop: Sidebar, Mobile: Top */}
<div className="card" style={{ marginBottom: '2rem' }} id="leaderboard-mobile">
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
{leaderboard.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{leaderboard.map((entry, index) => (
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
<strong>{entry.username}</strong>
</div>
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
{entry.validated_points} pts
</span>
</div>
))}
</div>
)}
</div>
<div className="challenge-detail-layout">
{/* Main Content */}
<div className="challenge-main">
{/* New Prediction */}
<div className="card" style={{ marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>Make a Prediction</h3>
<form onSubmit={handleCreatePrediction}>
<textarea
className="textarea"
placeholder="I predict that..."
value={newPrediction}
onChange={(e) => setNewPrediction(e.target.value)}
style={{ minHeight: '80px' }}
/>
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Prediction'}
</button>
</form>
</div>
{/* Predictions Feed */}
<h3 style={{ marginBottom: '1rem' }}>Predictions</h3>
{predictions.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
<p style={{ color: 'var(--text-muted)' }}>No predictions yet. Be the first!</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{predictions.map(pred => (
<div key={pred.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<strong>{pred.username}</strong>
<span style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
{new Date(pred.created_at).toLocaleDateString()}
</span>
</div>
<p style={{ marginBottom: '1rem' }}>{pred.content}</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
{pred.status === 'pending' && pred.user_id !== user.id && (
<>
<button
className="btn btn-success btn-sm"
onClick={() => handleValidate(pred.id, 'validated')}
disabled={validating === pred.id}
>
{validating === pred.id ? '...' : '✓ Validate'}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleValidate(pred.id, 'invalidated')}
disabled={validating === pred.id}
>
{validating === pred.id ? '...' : '✗ Invalidate'}
</button>
</>
)}
{pred.status === 'validated' && (
<span style={{ color: 'var(--success)', fontSize: '0.875rem' }}>
Validated by {pred.validated_by_username}
</span>
)}
{pred.status === 'invalidated' && (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>
Invalidated by {pred.validated_by_username}
</span>
)}
{pred.status === 'pending' && (
<span style={{ color: 'var(--warning)', fontSize: '0.875rem' }}>
Pending validation
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Sidebar - Leaderboard (Desktop only) */}
<div className="challenge-sidebar">
<div className="card">
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
{leaderboard.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{leaderboard.map((entry, index) => (
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
<strong>{entry.username}</strong>
</div>
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
{entry.validated_points} pts
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}