265 lines
9.4 KiB
JavaScript
265 lines
9.4 KiB
JavaScript
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([]);
|
|
const [pendingInvites, setPendingInvites] = useState([]);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [showResults, setShowResults] = useState([]);
|
|
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();
|
|
setChallenges(data.challenges);
|
|
|
|
// Filter out pending invitations
|
|
const pending = data.challenges.filter(c => c.participation_status === 'pending');
|
|
setPendingInvites(pending);
|
|
} catch (err) {
|
|
console.error('Failed to load challenges:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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) {
|
|
toast.error('Failed to respond: ' + err.message);
|
|
} finally {
|
|
setRespondingTo(null);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (query) => {
|
|
setSearchQuery(query);
|
|
|
|
// Clear previous timeout
|
|
if (searchTimeout) {
|
|
clearTimeout(searchTimeout);
|
|
}
|
|
|
|
if (query.trim().length < 2) {
|
|
setShowResults([]);
|
|
return;
|
|
}
|
|
|
|
// Debounce search by 1.5 seconds
|
|
const timeout = setTimeout(async () => {
|
|
try {
|
|
const data = await api.searchShows(query);
|
|
setShowResults(data.results || []);
|
|
} catch (err) {
|
|
console.error('Search failed:', err);
|
|
}
|
|
}, 1500);
|
|
|
|
setSearchTimeout(timeout);
|
|
};
|
|
|
|
const handleCreateChallenge = async (show) => {
|
|
setCreating(true);
|
|
try {
|
|
const coverImage = show.poster_path
|
|
? `https://image.tmdb.org/t/p/w500${show.poster_path}`
|
|
: null;
|
|
|
|
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) {
|
|
toast.error('Failed to create challenge: ' + err.message);
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <div className="loading">Loading challenges...</div>;
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '2rem 0' }}>
|
|
<div className="container">
|
|
<h1 style={{ marginBottom: '2rem' }}>My Challenges</h1>
|
|
|
|
{/* Pending Invitations */}
|
|
{pendingInvites.length > 0 && (
|
|
<div className="card" style={{ marginBottom: '2rem', background: 'var(--bg-lighter)' }}>
|
|
<h3 style={{ marginBottom: '1rem' }}>📬 Pending Invitations</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
|
{pendingInvites.map(challenge => (
|
|
<div key={challenge.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', flex: 1 }}>
|
|
{challenge.cover_image_url && (
|
|
<img
|
|
src={challenge.cover_image_url}
|
|
alt={challenge.title}
|
|
style={{ width: '40px', height: '60px', objectFit: 'cover', borderRadius: '0.25rem' }}
|
|
/>
|
|
)}
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{challenge.title}</div>
|
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
|
Invited by {challenge.creator_username}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
<button
|
|
className="btn btn-success btn-sm"
|
|
onClick={() => handleRespondToInvite(challenge.id, 'accepted')}
|
|
disabled={respondingTo === challenge.id}
|
|
>
|
|
{respondingTo === challenge.id ? '...' : 'Accept'}
|
|
</button>
|
|
<button
|
|
className="btn btn-danger btn-sm"
|
|
onClick={() => handleRespondToInvite(challenge.id, 'rejected')}
|
|
disabled={respondingTo === challenge.id}
|
|
>
|
|
{respondingTo === challenge.id ? '...' : 'Decline'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Search/Create */}
|
|
<div style={{ marginBottom: '2rem', position: 'relative' }} ref={searchRef}>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
placeholder="Search for a show or movie to create a challenge..."
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
/>
|
|
|
|
{showResults.length > 0 && (
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
right: 0,
|
|
background: 'var(--bg-light)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '0.5rem',
|
|
marginTop: '0.5rem',
|
|
maxHeight: '400px',
|
|
overflowY: 'auto',
|
|
zIndex: 10
|
|
}}>
|
|
{showResults.map(show => (
|
|
<div
|
|
key={show.id}
|
|
onClick={() => handleCreateChallenge(show)}
|
|
style={{
|
|
padding: '1rem',
|
|
borderBottom: '1px solid var(--border)',
|
|
cursor: 'pointer',
|
|
display: 'flex',
|
|
gap: '1rem',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
{show.poster_path && (
|
|
<img
|
|
src={`https://image.tmdb.org/t/p/w92${show.poster_path}`}
|
|
alt={show.title}
|
|
style={{ width: '46px', height: '69px', objectFit: 'cover', borderRadius: '0.25rem' }}
|
|
/>
|
|
)}
|
|
<div>
|
|
<div style={{ fontWeight: 500 }}>{show.title}</div>
|
|
<div style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
|
{show.media_type === 'tv' ? 'TV Show' : 'Movie'} • {show.release_date?.substring(0, 4)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Challenge List */}
|
|
{challenges.filter(c => c.participation_status !== 'pending').length === 0 ? (
|
|
<div className="card" style={{ textAlign: 'center', padding: '3rem' }}>
|
|
<p style={{ color: 'var(--text-muted)' }}>No challenges yet. Search for a show above to create one!</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-2">
|
|
{challenges.filter(c => c.participation_status !== 'pending').map(challenge => (
|
|
<Link key={challenge.id} to={`/challenges/${challenge.id}`} style={{ textDecoration: 'none' }}>
|
|
<div className="card" style={{ height: '100%', display: 'flex', gap: '1rem' }}>
|
|
{challenge.cover_image_url && (
|
|
<img
|
|
src={challenge.cover_image_url}
|
|
alt={challenge.title}
|
|
style={{ width: '80px', height: '120px', objectFit: 'cover', borderRadius: '0.5rem' }}
|
|
/>
|
|
)}
|
|
<div style={{ flex: 1 }}>
|
|
<h3 style={{ marginBottom: '0.5rem' }}>{challenge.title}</h3>
|
|
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
|
|
Created by {challenge.creator_username}
|
|
</p>
|
|
<p style={{ marginTop: '0.5rem', color: 'var(--primary)' }}>
|
|
{challenge.my_points} points
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|