Files
spotlightcam/frontend/src/pages/EventChatPage.jsx
Radosław Gierwiało 634cd97032 refactor(frontend): simplify event chat UI
- Replace "Edit heats" button with icon-only version
- Remove connection status indicator (show "Disconnected" warning only when offline)
- Remove event location from header
- Hide Participants tab on desktop (sidebar already visible)
- Remove "Participants" header from sidebar on desktop
2025-11-29 15:18:22 +01:00

561 lines
19 KiB
JavaScript

import { useState, useRef, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import Layout from '../components/layout/Layout';
import { useAuth } from '../contexts/AuthContext';
import { Send, UserPlus, Loader2, LogOut, AlertTriangle, QrCode, Edit2, Filter, X, MessageSquare, Users, Video } from 'lucide-react';
import { connectSocket, disconnectSocket, getSocket } from '../services/socket';
import { eventsAPI, heatsAPI, matchesAPI } from '../services/api';
import HeatsBanner from '../components/heats/HeatsBanner';
import HeatBadges from '../components/heats/HeatBadges';
import Avatar from '../components/common/Avatar';
import ChatMessageList from '../components/chat/ChatMessageList';
import ChatInput from '../components/chat/ChatInput';
import ConfirmationModal from '../components/modals/ConfirmationModal';
import Modal from '../components/modals/Modal';
import useEventChat from '../hooks/useEventChat';
import ParticipantsSidebar from '../components/events/ParticipantsSidebar';
import RecordingTab from '../components/recordings/RecordingTab';
const EventChatPage = () => {
const { slug } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [isParticipant, setIsParticipant] = useState(false);
const [loading, setLoading] = useState(true);
const [checkedInUsers, setCheckedInUsers] = useState([]);
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [isLeaving, setIsLeaving] = useState(false);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
// Event Chat hook - manages messages, Socket.IO, and active users
const {
messages,
newMessage,
setNewMessage,
activeUsers,
isConnected,
loadingOlder,
hasMore,
sendMessage: handleSendMessage,
loadOlderMessages
} = useEventChat(slug, user?.id, event, messagesContainerRef);
// Heats state
const [myHeats, setMyHeats] = useState([]);
const [myCompetitorNumber, setMyCompetitorNumber] = useState(null);
const [userHeats, setUserHeats] = useState(new Map());
const [userCompetitorNumbers, setUserCompetitorNumbers] = useState(new Map());
const [showHeatsBanner, setShowHeatsBanner] = useState(false);
const [hideMyHeats, setHideMyHeats] = useState(false);
const [showHeatsModal, setShowHeatsModal] = useState(false);
// Tab state: 'chat' | 'participants' | 'recording'
const [activeTab, setActiveTab] = useState('chat');
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
// Fetch event data and check participation
useEffect(() => {
const fetchEvent = async () => {
try {
setLoading(true);
// Get all events with participation info
const allEvents = await eventsAPI.getAll();
const eventData = allEvents.find(e => e.slug === slug);
if (eventData) {
setEvent(eventData);
setIsParticipant(eventData.isJoined);
} else {
setEvent(null);
setIsParticipant(false);
}
} catch (err) {
console.error('Error loading event:', err);
setEvent(null);
setIsParticipant(false);
} finally {
setLoading(false);
}
};
fetchEvent();
}, [slug]);
// Load heats data and checked-in users
useEffect(() => {
if (!event || !isParticipant) return;
const loadData = async () => {
try {
// Load my heats and competitor number
const myHeatsResponse = await heatsAPI.getMyHeats(slug);
const myHeatsData = myHeatsResponse.data || [];
setMyHeats(myHeatsData);
setMyCompetitorNumber(myHeatsResponse.competitorNumber);
setShowHeatsBanner(myHeatsData.length === 0);
// Load all users' heats and competitor numbers
const allHeatsData = await heatsAPI.getAllHeats(slug);
const heatsMap = new Map();
const competitorNumbersMap = new Map();
allHeatsData.forEach((userHeat) => {
heatsMap.set(userHeat.userId, userHeat.heats);
if (userHeat.competitorNumber) {
competitorNumbersMap.set(userHeat.userId, userHeat.competitorNumber);
}
});
setUserHeats(heatsMap);
setUserCompetitorNumbers(competitorNumbersMap);
// Load all checked-in users (participants)
const eventDetails = await eventsAPI.getDetails(slug);
if (eventDetails.data && eventDetails.data.participants) {
const participants = eventDetails.data.participants
.map(p => ({
userId: p.userId,
username: p.username,
avatar: p.avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${p.username}`,
firstName: p.firstName,
lastName: p.lastName,
}))
.filter(p => p.userId !== user.id); // Exclude current user
setCheckedInUsers(participants);
}
} catch (error) {
console.error('Failed to load data:', error);
}
};
loadData();
}, [event, isParticipant, slug, user.id]);
useEffect(() => {
scrollToBottom();
}, [messages]);
// Heats updates listener (specific to EventChatPage)
useEffect(() => {
if (!event) return;
const socket = getSocket();
if (!socket) return;
// Heats updated notification
const handleHeatsUpdated = (data) => {
const { userId, heats } = data;
// Update userHeats map
setUserHeats((prev) => {
const newMap = new Map(prev);
if (heats && heats.length > 0) {
newMap.set(userId, heats);
} else {
newMap.delete(userId);
}
return newMap;
});
// If it's the current user, update myHeats
if (userId === user.id) {
setMyHeats(heats || []);
setShowHeatsBanner(heats.length === 0);
}
};
socket.on('heats_updated', handleHeatsUpdated);
return () => {
socket.off('heats_updated', handleHeatsUpdated);
};
}, [event, user.id]);
const handleMatchWith = async (userId) => {
try {
const result = await matchesAPI.createMatch(userId, slug);
// Show success message
alert(`Match request sent successfully! The user will be notified.`);
// Optional: Navigate to matches page or refresh matches list
// For now, we just show a success message
} catch (error) {
console.error('Failed to send match request:', error);
// Show appropriate error message
if (error.status === 400 && error.message.includes('already exists')) {
alert('You already have a match request with this user.');
} else if (error.status === 403) {
alert('You must be a participant of this event to send match requests.');
} else if (error.status === 404) {
alert('Event not found.');
} else {
alert('Failed to send match request. Please try again.');
}
}
};
const handleHeatsSave = () => {
setShowHeatsBanner(false);
setShowHeatsModal(false);
// Heats will be updated via Socket.IO heats_updated event
};
const shouldHideUser = (userId) => {
if (!hideMyHeats || myHeats.length === 0) return false;
const targetUserHeats = userHeats.get(userId);
if (!targetUserHeats || targetUserHeats.length === 0) return false;
// Hide if ANY of their heats match ANY of my heats (same division + competition_type + heat_number)
return targetUserHeats.some((targetHeat) =>
myHeats.some(
(myHeat) =>
myHeat.divisionId === targetHeat.divisionId &&
myHeat.competitionTypeId === targetHeat.competitionTypeId &&
myHeat.heatNumber === targetHeat.heatNumber
)
);
};
// Combine checked-in users with online status
const getAllDisplayUsers = () => {
const activeUserIds = new Set(activeUsers.map(u => u.userId));
// Merge checked-in users with online status
const allUsers = checkedInUsers.map(user => ({
...user,
isOnline: activeUserIds.has(user.userId),
}));
// Sort: online first, then offline
return allUsers.sort((a, b) => {
if (a.isOnline === b.isOnline) return 0;
return a.isOnline ? -1 : 1;
});
};
const handleLeaveEvent = async () => {
try {
setIsLeaving(true);
await eventsAPI.leave(slug);
// Disconnect socket
const socket = getSocket();
if (socket) {
socket.emit('leave_event_room');
}
// Redirect to events page
navigate('/events');
} catch (error) {
console.error('Failed to leave event:', error);
alert('Failed to leave event. Please try again.');
} finally {
setIsLeaving(false);
setShowLeaveModal(false);
}
};
// Infinite scroll - detect scroll to top
useEffect(() => {
const container = messagesContainerRef.current;
if (!container) return;
const handleScroll = () => {
if (container.scrollTop < 100 && !loadingOlder && hasMore) {
loadOlderMessages();
}
};
container.addEventListener('scroll', handleScroll);
return () => container.removeEventListener('scroll', handleScroll);
}, [loadingOlder, hasMore, messages]);
if (loading) {
return (
<Layout>
<div className="max-w-4xl mx-auto flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-12 h-12 animate-spin text-primary-600" />
<p className="text-gray-600">Loading event...</p>
</div>
</div>
</Layout>
);
}
if (!event) {
return (
<Layout>
<div className="max-w-4xl mx-auto">
<div className="bg-red-50 border border-red-200 rounded-md p-4 text-red-700">
Event not found
</div>
</div>
</Layout>
);
}
// Check if user is participant
if (!isParticipant) {
return (
<Layout>
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center">
<div className="w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mx-auto mb-4">
<QrCode className="w-8 h-8 text-amber-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Check-in Required
</h2>
<p className="text-gray-600 mb-6">
You need to check in at the event venue by scanning the QR code to access this chat.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
<p className="text-sm text-amber-800 font-medium mb-1">
{event.name}
</p>
<p className="text-sm text-amber-700">
{event.location}
</p>
</div>
<div className="flex gap-3 justify-center">
<Link
to="/events"
className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Back to Events
</Link>
{import.meta.env.DEV && (
<Link
to={`/events/${slug}/details`}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
View QR Code (dev)
</Link>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}
return (
<Layout fullWidth>
<div className="flex flex-col h-[calc(100vh-64px)] bg-white">
{/* Header */}
<div className="bg-primary-600 text-white p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<h2 className="text-2xl font-bold">{event.name}</h2>
<div className="mt-2 flex items-center gap-3">
{/* My Heats Display */}
{myHeats.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-primary-100">Your heats:</span>
<HeatBadges
heats={myHeats}
maxVisible={10}
badgeClassName="text-xs px-2 py-0.5 bg-primary-700 text-white rounded font-mono"
/>
</div>
)}
</div>
</div>
<div className="flex items-center gap-2">
{myHeats.length > 0 && (
<button
onClick={() => setShowHeatsModal(true)}
className="p-2 bg-primary-700 hover:bg-primary-800 rounded-md transition-colors"
title="Edit heats"
>
<Edit2 className="w-4 h-4" />
</button>
)}
<button
onClick={() => setShowLeaveModal(true)}
className="p-2 bg-red-600 hover:bg-red-700 rounded-md transition-colors"
title="Leave Event"
>
<LogOut size={16} />
</button>
</div>
</div>
</div>
{/* Disconnected Warning - show only when disconnected */}
{!isConnected && (
<div className="bg-red-600 text-white px-4 py-2 text-sm flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span>Disconnected</span>
</div>
)}
{/* Heats Banner */}
{showHeatsBanner && (
<HeatsBanner
slug={slug}
onSave={handleHeatsSave}
onDismiss={() => setShowHeatsBanner(false)}
existingCompetitorNumber={myCompetitorNumber}
/>
)}
{/* Tab Navigation */}
<div className="border-b bg-gray-50">
<nav className="flex">
<button
onClick={() => setActiveTab('chat')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'chat'
? 'border-primary-600 text-primary-600 bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<MessageSquare className="w-4 h-4" />
Chat
</button>
<button
onClick={() => setActiveTab('participants')}
className={`lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'participants'
? 'border-primary-600 text-primary-600 bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Users className="w-4 h-4" />
Participants
<span className="ml-1 px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded-full">
{checkedInUsers.length}
</span>
</button>
<button
onClick={() => setActiveTab('recording')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'recording'
? 'border-primary-600 text-primary-600 bg-white'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Video className="w-4 h-4" />
Recording
</button>
</nav>
</div>
{/* Content Area - flex-1 fills remaining space */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Chat Tab */}
{activeTab === 'chat' && (
<div className="flex h-full">
{/* Sidebar - visible only on chat tab on larger screens */}
<div className="hidden lg:block">
<ParticipantsSidebar
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
activeUsers={activeUsers}
userHeats={userHeats}
userCompetitorNumbers={userCompetitorNumbers}
myHeats={myHeats}
hideMyHeats={hideMyHeats}
onHideMyHeatsChange={setHideMyHeats}
onMatchWith={handleMatchWith}
showHeader={false}
/>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col">
<ChatMessageList
messages={messages}
currentUserId={user.id}
messagesEndRef={messagesEndRef}
messagesContainerRef={messagesContainerRef}
loadingOlder={loadingOlder}
hasMore={hasMore}
/>
{/* Message Input */}
<div className="border-t p-4">
<ChatInput
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onSubmit={handleSendMessage}
disabled={!isConnected}
placeholder="Write a message..."
/>
</div>
</div>
</div>
)}
{/* Participants Tab */}
{activeTab === 'participants' && (
<div className="h-full overflow-y-auto p-4">
<ParticipantsSidebar
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
activeUsers={activeUsers}
userHeats={userHeats}
userCompetitorNumbers={userCompetitorNumbers}
myHeats={myHeats}
hideMyHeats={hideMyHeats}
onHideMyHeatsChange={setHideMyHeats}
onMatchWith={handleMatchWith}
fullWidth={true}
/>
</div>
)}
{/* Recording Tab */}
{activeTab === 'recording' && (
<RecordingTab
slug={slug}
event={event}
myHeats={myHeats}
/>
)}
</div>
</div>
<ConfirmationModal
isOpen={showLeaveModal}
onClose={() => setShowLeaveModal(false)}
onConfirm={handleLeaveEvent}
title="Leave Event?"
description="This action cannot be undone"
message={`Are you sure you want to leave ${event?.name || 'this event'}? You will need to scan the QR code again to rejoin.`}
confirmText="Leave Event"
isLoading={isLeaving}
loadingText="Leaving..."
icon={AlertTriangle}
/>
<Modal
isOpen={showHeatsModal}
onClose={() => setShowHeatsModal(false)}
title="Edit Your Competition Heats"
>
<HeatsBanner
slug={slug}
onSave={handleHeatsSave}
onDismiss={() => setShowHeatsModal(false)}
existingHeats={myHeats}
existingCompetitorNumber={myCompetitorNumber}
/>
</Modal>
</Layout>
);
};
export default EventChatPage;