diff --git a/frontend/src/components/events/ParticipantsSidebar.jsx b/frontend/src/components/events/ParticipantsSidebar.jsx
new file mode 100644
index 0000000..f345ce1
--- /dev/null
+++ b/frontend/src/components/events/ParticipantsSidebar.jsx
@@ -0,0 +1,106 @@
+import { Filter, UserPlus } from 'lucide-react';
+import UserListItem from '../users/UserListItem';
+
+/**
+ * Participants Sidebar component for Event Chat
+ * Displays list of event participants with heats and match actions
+ *
+ * @param {Array} users - Array of user objects to display (already filtered)
+ * @param {Array} activeUsers - Array of currently online users (for count)
+ * @param {Map} userHeats - Map of userId -> heats array
+ * @param {Array} myHeats - Current user's heats (for filter checkbox visibility)
+ * @param {boolean} hideMyHeats - Filter state
+ * @param {function} onHideMyHeatsChange - Filter change handler
+ * @param {function} onMatchWith - Handler for match button click (receives userId)
+ * @param {string} className - Additional CSS classes
+ *
+ * @example
+ *
+ */
+const ParticipantsSidebar = ({
+ users = [],
+ activeUsers = [],
+ userHeats = new Map(),
+ myHeats = [],
+ hideMyHeats = false,
+ onHideMyHeatsChange,
+ onMatchWith,
+ className = ''
+}) => {
+ const participantCount = users.length;
+ const onlineCount = activeUsers.length;
+
+ return (
+
+ {/* Header */}
+
+
+ Participants ({participantCount})
+
+
+ {onlineCount} online
+
+
+ {/* Filter Checkbox */}
+ {myHeats.length > 0 && (
+
+ )}
+
+
+ {/* Empty State */}
+ {participantCount === 0 && (
+
No other participants
+ )}
+
+ {/* User List */}
+
+ {users.map((displayUser) => {
+ const thisUserHeats = userHeats.get(displayUser.userId) || [];
+ const hasHeats = thisUserHeats.length > 0;
+
+ return (
+ onMatchWith?.(displayUser.userId)}
+ disabled={!hasHeats}
+ className={`p-1 rounded flex-shrink-0 ${
+ hasHeats
+ ? 'text-primary-600 hover:bg-primary-50'
+ : 'text-gray-300 cursor-not-allowed'
+ }`}
+ title={hasHeats ? 'Connect' : 'User has not declared heats'}
+ >
+
+
+ }
+ />
+ );
+ })}
+
+
+ );
+};
+
+export default ParticipantsSidebar;
diff --git a/frontend/src/components/heats/HeatBadges.jsx b/frontend/src/components/heats/HeatBadges.jsx
new file mode 100644
index 0000000..feb5018
--- /dev/null
+++ b/frontend/src/components/heats/HeatBadges.jsx
@@ -0,0 +1,65 @@
+/**
+ * Reusable Heat Badges component
+ * Displays competition heats with compact notation (e.g., "J&J NOV 1 L")
+ *
+ * @param {Array} heats - Array of heat objects with { competitionType, division, heatNumber, role }
+ * @param {number} maxVisible - Maximum number of badges to show before "+X more" (default: 3)
+ * @param {boolean} compact - Use compact display (default: true)
+ * @param {string} className - Additional CSS classes for container
+ *
+ * @example
+ *
+ * // Renders: "J&J NOV 1 L" "STR INT 2 F" "+2"
+ */
+const HeatBadges = ({
+ heats = [],
+ maxVisible = 3,
+ compact = true,
+ className = ''
+}) => {
+ if (!heats || heats.length === 0) return null;
+
+ /**
+ * Format heat object into compact notation
+ * Example: { competitionType: 'Jack & Jill', division: 'Novice', heatNumber: 1, role: 'Leader' }
+ * Returns: "J&J NOV 1 L"
+ */
+ const formatHeat = (heat) => {
+ const parts = [
+ heat.competitionType?.abbreviation || '',
+ heat.division?.abbreviation || '',
+ heat.heatNumber,
+ ];
+ if (heat.role) {
+ parts.push(heat.role.charAt(0)); // L or F
+ }
+ return parts.join(' ');
+ };
+
+ const visibleHeats = heats.slice(0, maxVisible);
+ const remainingCount = heats.length - maxVisible;
+
+ return (
+
+ {visibleHeats.map((heat, idx) => (
+
+ {formatHeat(heat)}
+
+ ))}
+ {remainingCount > 0 && (
+ 1 ? 's' : ''}`}
+ >
+ +{remainingCount}
+
+ )}
+
+ );
+};
+
+export default HeatBadges;
diff --git a/frontend/src/components/users/UserListItem.jsx b/frontend/src/components/users/UserListItem.jsx
new file mode 100644
index 0000000..f7ee227
--- /dev/null
+++ b/frontend/src/components/users/UserListItem.jsx
@@ -0,0 +1,93 @@
+import { Link } from 'react-router-dom';
+import Avatar from '../common/Avatar';
+import HeatBadges from '../heats/HeatBadges';
+
+/**
+ * Reusable User List Item component
+ * Displays user avatar, username, and optional heats with action button
+ *
+ * @param {object} user - User object with { userId, username, avatar, isOnline, firstName, lastName }
+ * @param {Array} heats - Array of heat objects for this user
+ * @param {boolean} showHeats - Whether to display heat badges (default: true)
+ * @param {React.ReactNode} actionButton - Optional action button/element to display on right
+ * @param {function} onClick - Optional click handler for the entire item
+ * @param {string} className - Additional CSS classes
+ * @param {boolean} linkToProfile - Whether username should link to profile (default: false)
+ *
+ * @example
+ * handleMatch(userId)}>
+ *
+ *
+ * }
+ * />
+ */
+const UserListItem = ({
+ user,
+ heats = [],
+ showHeats = true,
+ actionButton,
+ onClick,
+ className = '',
+ linkToProfile = false
+}) => {
+ const hasHeats = heats && heats.length > 0;
+ const isOnline = user?.isOnline ?? false;
+
+ const usernameClasses = `text-sm font-medium truncate ${
+ isOnline ? 'text-gray-900' : 'text-gray-500'
+ }`;
+
+ const usernameContent = linkToProfile ? (
+
+ {user.username}
+
+ ) : (
+ {user.username}
+ );
+
+ return (
+
+
+
+
+ {usernameContent}
+ {/* Full name (optional) */}
+ {(user.firstName || user.lastName) && (
+
+ {user.firstName} {user.lastName}
+
+ )}
+ {/* Heat Badges */}
+ {showHeats && hasHeats && (
+
+
+
+ )}
+
+
+ {actionButton && (
+
+ {actionButton}
+
+ )}
+
+ );
+};
+
+export default UserListItem;
diff --git a/frontend/src/components/webrtc/FileTransferProgress.jsx b/frontend/src/components/webrtc/FileTransferProgress.jsx
new file mode 100644
index 0000000..619b177
--- /dev/null
+++ b/frontend/src/components/webrtc/FileTransferProgress.jsx
@@ -0,0 +1,99 @@
+import { Video, Upload, X } from 'lucide-react';
+
+/**
+ * File Transfer Progress component for WebRTC P2P file sharing
+ * Displays selected file info, progress bar, and action buttons
+ *
+ * @param {File} file - Selected file object
+ * @param {number} progress - Transfer progress percentage (0-100)
+ * @param {boolean} isTransferring - Whether file is currently being transferred
+ * @param {function} onCancel - Cancel transfer handler
+ * @param {function} onSend - Start transfer handler
+ * @param {function} onRemoveFile - Remove selected file handler (X button)
+ *
+ * @example
+ * setSelectedFile(null)}
+ * />
+ */
+const FileTransferProgress = ({
+ file,
+ progress = 0,
+ isTransferring = false,
+ onCancel,
+ onSend,
+ onRemoveFile
+}) => {
+ if (!file) return null;
+
+ const fileSizeMB = file ? (file.size / 1024 / 1024).toFixed(2) : '0.00';
+
+ return (
+
+
+ {/* File Info Header */}
+
+
+
+
+
{file.name}
+
{fileSizeMB} MB
+
+
+ {!isTransferring && onRemoveFile && (
+
+ )}
+
+
+ {/* Progress Bar & Actions */}
+ {isTransferring ? (
+ <>
+
+
+ Transferring via WebRTC...
+ {progress}%
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default FileTransferProgress;
diff --git a/frontend/src/components/webrtc/LinkShareInput.jsx b/frontend/src/components/webrtc/LinkShareInput.jsx
new file mode 100644
index 0000000..32bfd7b
--- /dev/null
+++ b/frontend/src/components/webrtc/LinkShareInput.jsx
@@ -0,0 +1,78 @@
+/**
+ * Link Share Input component
+ * Fallback for WebRTC - allows sharing video links (Google Drive, Dropbox, etc.)
+ *
+ * @param {boolean} isOpen - Whether the input form is visible
+ * @param {string} link - Current link value
+ * @param {function} onChange - Link input change handler
+ * @param {function} onSubmit - Form submit handler
+ * @param {function} onCancel - Cancel/close handler
+ *
+ * @example
+ * setVideoLink(e.target.value)}
+ * onSubmit={handleSendLink}
+ * onCancel={() => {
+ * setShowLinkInput(false);
+ * setVideoLink('');
+ * }}
+ * />
+ */
+const LinkShareInput = ({
+ isOpen,
+ link,
+ onChange,
+ onSubmit,
+ onCancel
+}) => {
+ if (!isOpen) return null;
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (link.trim()) {
+ onSubmit?.(e);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default LinkShareInput;
diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx
index a79b937..685afc4 100644
--- a/frontend/src/pages/EventChatPage.jsx
+++ b/frontend/src/pages/EventChatPage.jsx
@@ -12,6 +12,7 @@ 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';
const EventChatPage = () => {
const { slug } = useParams();
@@ -191,18 +192,6 @@ const EventChatPage = () => {
// Heats will be updated via Socket.IO heats_updated event
};
- const formatHeat = (heat) => {
- const parts = [
- heat.competitionType?.abbreviation || '',
- heat.division?.abbreviation || '',
- heat.heatNumber,
- ];
- if (heat.role) {
- parts.push(heat.role.charAt(0)); // L or F
- }
- return parts.join(' ');
- };
-
const shouldHideUser = (userId) => {
if (!hideMyHeats || myHeats.length === 0) return false;
@@ -403,97 +392,15 @@ const EventChatPage = () => {
)}
- {/* Participants Sidebar */}
-
-
-
- Participants ({getAllDisplayUsers().filter(u => !shouldHideUser(u.userId)).length})
-
-
- {activeUsers.length} online
-
-
- {/* Filter Checkbox */}
- {myHeats.length > 0 && (
-
- )}
-
-
- {getAllDisplayUsers().filter(u => !shouldHideUser(u.userId)).length === 0 && (
-
No other participants
- )}
-
- {getAllDisplayUsers()
- .filter((displayUser) => !shouldHideUser(displayUser.userId))
- .map((displayUser) => {
- const thisUserHeats = userHeats.get(displayUser.userId) || [];
- const hasHeats = thisUserHeats.length > 0;
-
- return (
-
-
-
-
-
- {displayUser.username}
-
- {/* Heat Badges */}
- {hasHeats && (
-
- {thisUserHeats.slice(0, 3).map((heat, idx) => (
-
- {formatHeat(heat)}
-
- ))}
- {thisUserHeats.length > 3 && (
-
- +{thisUserHeats.length - 3}
-
- )}
-
- )}
-
-
-
-
- );
- })}
-
-
+
!shouldHideUser(u.userId))}
+ activeUsers={activeUsers}
+ userHeats={userHeats}
+ myHeats={myHeats}
+ hideMyHeats={hideMyHeats}
+ onHideMyHeatsChange={setHideMyHeats}
+ onMatchWith={handleMatchWith}
+ />
{/* Chat Area */}
diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx
index 11a76b2..c936686 100644
--- a/frontend/src/pages/MatchChatPage.jsx
+++ b/frontend/src/pages/MatchChatPage.jsx
@@ -12,6 +12,8 @@ import Avatar from '../components/common/Avatar';
import ChatMessageList from '../components/chat/ChatMessageList';
import ChatInput from '../components/chat/ChatInput';
import useMatchChat from '../hooks/useMatchChat';
+import FileTransferProgress from '../components/webrtc/FileTransferProgress';
+import LinkShareInput from '../components/webrtc/LinkShareInput';
const MatchChatPage = () => {
const { slug } = useParams();
@@ -287,104 +289,25 @@ const MatchChatPage = () => {
messagesEndRef={messagesEndRef}
/>
- {/* Video Transfer Section */}
- {(selectedFile || isTransferring) && (
-
-
-
-
-
-
-
{selectedFile?.name}
-
- {selectedFile && `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB`}
-
-
-
- {!isTransferring && (
-
- )}
-
+
setSelectedFile(null)}
+ />
- {isTransferring ? (
- <>
-
-
- Transferring via WebRTC...
- {transferProgress}%
-
-
-
-
- >
- ) : (
-
- )}
-
-
- )}
-
- {/* Link Input Section */}
- {showLinkInput && (
-
- )}
+
setVideoLink(e.target.value)}
+ onSubmit={handleSendLink}
+ onCancel={() => {
+ setShowLinkInput(false);
+ setVideoLink('');
+ }}
+ />
{/* Message Input & Actions */}