From 082105c5bff4a46196654ac2fea91504a51191ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 21 Nov 2025 17:10:53 +0100 Subject: [PATCH] refactor(frontend): Phase 3 - create advanced composite components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract complex UI sections into reusable composite components New Components Created: 1. HeatBadges (heats/HeatBadges.jsx) - Displays competition heats with compact notation - Configurable max visible badges with "+X more" overflow - Tooltips with full heat information 2. UserListItem (users/UserListItem.jsx) - Reusable user entry with avatar, username, full name - Optional heat badges display - Flexible action button slot (render props pattern) - Online/offline status support 3. ParticipantsSidebar (events/ParticipantsSidebar.jsx) - Complete sidebar component for event participants - Filter checkbox for hiding users from own heats - Participant and online counters - Integrated UserListItem with match actions 4. FileTransferProgress (webrtc/FileTransferProgress.jsx) - WebRTC P2P file transfer UI - Progress bar with percentage - Send/Cancel actions 5. LinkShareInput (webrtc/LinkShareInput.jsx) - Fallback link sharing when WebRTC unavailable - Google Drive, Dropbox link support Pages Refactored: - EventChatPage: 564 → 471 lines (-93 lines, -16%) * Replaced 90-line participants sidebar with * Removed duplicate formatHeat logic (now in HeatBadges) - MatchChatPage: 446 → 369 lines (-77 lines, -17%) * Replaced 56-line file transfer UI with * Replaced 39-line link input form with Phase 3 Total: -170 lines Grand Total (Phase 1+2+3): -559 lines (-17%) Final Results: - EventChatPage: 761 → 471 lines (-290 lines, -38% reduction) - MatchChatPage: 567 → 369 lines (-198 lines, -35% reduction) Benefits: - Massive complexity reduction in largest components - Composite components can be reused across pages - Better testability - each component tested independently - Cleaner code organization - single responsibility principle - Easier maintenance - changes in one place propagate everywhere --- .../components/events/ParticipantsSidebar.jsx | 106 ++++++++++++++++ frontend/src/components/heats/HeatBadges.jsx | 65 ++++++++++ .../src/components/users/UserListItem.jsx | 93 ++++++++++++++ .../webrtc/FileTransferProgress.jsx | 99 +++++++++++++++ .../src/components/webrtc/LinkShareInput.jsx | 78 ++++++++++++ frontend/src/pages/EventChatPage.jsx | 113 ++--------------- frontend/src/pages/MatchChatPage.jsx | 117 +++--------------- 7 files changed, 471 insertions(+), 200 deletions(-) create mode 100644 frontend/src/components/events/ParticipantsSidebar.jsx create mode 100644 frontend/src/components/heats/HeatBadges.jsx create mode 100644 frontend/src/components/users/UserListItem.jsx create mode 100644 frontend/src/components/webrtc/FileTransferProgress.jsx create mode 100644 frontend/src/components/webrtc/LinkShareInput.jsx 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 */} +
+
+
+ {!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) && ( -
-
-
-
-
- {!isTransferring && ( - - )} -
+ setSelectedFile(null)} + /> - {isTransferring ? ( - <> -
-
- Transferring via WebRTC... - {transferProgress}% -
-
-
-
-
- - - ) : ( - - )} -
-
- )} - - {/* Link Input Section */} - {showLinkInput && ( -
-
-
-
- - setVideoLink(e.target.value)} - placeholder="https://drive.google.com/..." - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500" - required - /> -
-
- - -
-
-
-
- )} + setVideoLink(e.target.value)} + onSubmit={handleSendLink} + onCancel={() => { + setShowLinkInput(false); + setVideoLink(''); + }} + /> {/* Message Input & Actions */}