setup features
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../AuthContext';
|
||||
import api from '../api';
|
||||
|
||||
export default function ChallengeDetail() {
|
||||
const { id } = useParams();
|
||||
const { user } = useAuth();
|
||||
const [challenge, setChallenge] = useState(null);
|
||||
const [predictions, setPredictions] = useState([]);
|
||||
const [leaderboard, setLeaderboard] = useState([]);
|
||||
const [newPrediction, setNewPrediction] = useState('');
|
||||
const [inviteQuery, setInviteQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadChallenge();
|
||||
loadPredictions();
|
||||
loadLeaderboard();
|
||||
}, [id]);
|
||||
|
||||
const loadChallenge = async () => {
|
||||
try {
|
||||
const data = await api.getChallenge(id);
|
||||
setChallenge(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load challenge:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPredictions = async () => {
|
||||
try {
|
||||
const data = await api.getPredictions(id);
|
||||
setPredictions(data.predictions);
|
||||
} catch (err) {
|
||||
console.error('Failed to load predictions:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
try {
|
||||
const data = await api.getChallengeLeaderboard(id);
|
||||
setLeaderboard(data.leaderboard);
|
||||
} catch (err) {
|
||||
console.error('Failed to load leaderboard:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePrediction = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!newPrediction.trim()) return;
|
||||
|
||||
try {
|
||||
await api.createPrediction({
|
||||
challenge_id: id,
|
||||
content: newPrediction
|
||||
});
|
||||
setNewPrediction('');
|
||||
await loadPredictions();
|
||||
} catch (err) {
|
||||
alert('Failed to create prediction: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = async (predictionId, status) => {
|
||||
try {
|
||||
await api.validatePrediction(predictionId, status);
|
||||
await loadPredictions();
|
||||
await loadLeaderboard();
|
||||
} catch (err) {
|
||||
alert('Failed to validate: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchUsers = async (query) => {
|
||||
setInviteQuery(query);
|
||||
if (query.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.searchUsers(query);
|
||||
setSearchResults(data.users);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInvite = async (userId) => {
|
||||
try {
|
||||
await api.inviteToChallenge(id, { user_ids: [userId] });
|
||||
setInviteQuery('');
|
||||
setSearchResults([]);
|
||||
alert('Invitation sent!');
|
||||
} catch (err) {
|
||||
alert('Failed to send invite: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">Loading challenge...</div>;
|
||||
}
|
||||
|
||||
if (!challenge) {
|
||||
return <div className="loading">Challenge not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<Link to="/challenges" style={{ color: 'var(--primary)', textDecoration: 'none' }}>← Back to Challenges</Link>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
|
||||
{challenge.challenge.cover_image_url && (
|
||||
<img
|
||||
src={challenge.challenge.cover_image_url}
|
||||
alt={challenge.challenge.title}
|
||||
style={{ width: '150px', height: '225px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{ marginBottom: '0.5rem' }}>{challenge.challenge.title}</h1>
|
||||
<p style={{ color: 'var(--text-muted)', marginBottom: '1rem' }}>
|
||||
Created by {challenge.challenge.creator_username}
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setShowInvite(!showInvite)}
|
||||
>
|
||||
{showInvite ? 'Cancel' : 'Invite Friends'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Section */}
|
||||
{showInvite && (
|
||||
<div className="card" style={{ marginBottom: '2rem', position: 'relative' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Invite Someone</h3>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by username or email..."
|
||||
value={inviteQuery}
|
||||
onChange={(e) => handleSearchUsers(e.target.value)}
|
||||
/>
|
||||
{searchResults.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'var(--bg)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.5rem',
|
||||
marginTop: '0.5rem',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 10
|
||||
}}>
|
||||
{searchResults.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleInvite(user.id)}
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500 }}>{user.username}</div>
|
||||
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>{user.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '2rem', gridTemplateColumns: 'minmax(0, 1fr) 300px' }}>
|
||||
{/* Main Content */}
|
||||
<div>
|
||||
{/* New Prediction */}
|
||||
<div className="card" style={{ marginBottom: '2rem' }}>
|
||||
<h3 style={{ marginBottom: '1rem' }}>Make a Prediction</h3>
|
||||
<form onSubmit={handleCreatePrediction}>
|
||||
<textarea
|
||||
className="textarea"
|
||||
placeholder="I predict that..."
|
||||
value={newPrediction}
|
||||
onChange={(e) => setNewPrediction(e.target.value)}
|
||||
style={{ minHeight: '80px' }}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" style={{ marginTop: '1rem' }}>
|
||||
Submit Prediction
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Predictions Feed */}
|
||||
<h3 style={{ marginBottom: '1rem' }}>Predictions</h3>
|
||||
{predictions.length === 0 ? (
|
||||
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<p style={{ color: 'var(--text-muted)' }}>No predictions yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
{predictions.map(pred => (
|
||||
<div key={pred.id} className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
||||
<strong>{pred.username}</strong>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
||||
{new Date(pred.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ marginBottom: '1rem' }}>{pred.content}</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{pred.status === 'pending' && pred.user_id !== user.id && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'validated')}
|
||||
>
|
||||
✓ Validate
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
onClick={() => handleValidate(pred.id, 'invalidated')}
|
||||
>
|
||||
✗ Invalidate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pred.status === 'validated' && (
|
||||
<span style={{ color: 'var(--success)', fontSize: '0.875rem' }}>
|
||||
✓ Validated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'invalidated' && (
|
||||
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>
|
||||
✗ Invalidated by {pred.validated_by_username}
|
||||
</span>
|
||||
)}
|
||||
{pred.status === 'pending' && (
|
||||
<span style={{ color: 'var(--warning)', fontSize: '0.875rem' }}>
|
||||
⏳ Pending validation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Leaderboard */}
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '1rem' }}>Leaderboard</h3>
|
||||
{leaderboard.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>No points yet</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{leaderboard.map((entry, index) => (
|
||||
<div key={entry.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<span style={{ marginRight: '0.5rem', color: 'var(--text-muted)' }}>{index + 1}.</span>
|
||||
<strong>{entry.username}</strong>
|
||||
</div>
|
||||
<span style={{ color: 'var(--primary)', fontWeight: 600 }}>
|
||||
{entry.validated_points} pts
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user