refactor(frontend): Phase 3 - create advanced composite components

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 <ParticipantsSidebar />
  * Removed duplicate formatHeat logic (now in HeatBadges)

- MatchChatPage: 446 → 369 lines (-77 lines, -17%)
  * Replaced 56-line file transfer UI with <FileTransferProgress />
  * Replaced 39-line link input form with <LinkShareInput />

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
This commit is contained in:
Radosław Gierwiało
2025-11-21 17:10:53 +01:00
parent 9e74343c3b
commit 082105c5bf
7 changed files with 471 additions and 200 deletions

View File

@@ -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 = () => {
)}
<div className="flex h-[calc(100vh-280px)]">
{/* Participants Sidebar */}
<div className="w-64 border-r bg-gray-50 p-4 overflow-y-auto">
<div className="mb-4">
<h3 className="font-semibold text-gray-900 mb-2">
Participants ({getAllDisplayUsers().filter(u => !shouldHideUser(u.userId)).length})
</h3>
<p className="text-xs text-gray-500 mb-3">
{activeUsers.length} online
</p>
{/* Filter Checkbox */}
{myHeats.length > 0 && (
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer hover:text-gray-900">
<input
type="checkbox"
checked={hideMyHeats}
onChange={(e) => setHideMyHeats(e.target.checked)}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<Filter className="w-3 h-3" />
<span>Hide users from my heats</span>
</label>
)}
</div>
{getAllDisplayUsers().filter(u => !shouldHideUser(u.userId)).length === 0 && (
<p className="text-sm text-gray-500">No other participants</p>
)}
<div className="space-y-2">
{getAllDisplayUsers()
.filter((displayUser) => !shouldHideUser(displayUser.userId))
.map((displayUser) => {
const thisUserHeats = userHeats.get(displayUser.userId) || [];
const hasHeats = thisUserHeats.length > 0;
return (
<div
key={displayUser.userId}
className="flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg"
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
<Avatar
src={displayUser.avatar}
username={displayUser.username}
size={32}
status={displayUser.isOnline ? 'online' : 'offline'}
title={displayUser.username}
/>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${
displayUser.isOnline ? 'text-gray-900' : 'text-gray-500'
}`}>
{displayUser.username}
</p>
{/* Heat Badges */}
{hasHeats && (
<div className="flex flex-wrap gap-1 mt-1">
{thisUserHeats.slice(0, 3).map((heat, idx) => (
<span
key={idx}
className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded font-mono"
>
{formatHeat(heat)}
</span>
))}
{thisUserHeats.length > 3 && (
<span className="text-xs px-1.5 py-0.5 bg-gray-200 text-gray-700 rounded font-mono">
+{thisUserHeats.length - 3}
</span>
)}
</div>
)}
</div>
</div>
<button
onClick={() => handleMatchWith(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'}
>
<UserPlus className="w-4 h-4" />
</button>
</div>
);
})}
</div>
</div>
<ParticipantsSidebar
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
activeUsers={activeUsers}
userHeats={userHeats}
myHeats={myHeats}
hideMyHeats={hideMyHeats}
onHideMyHeatsChange={setHideMyHeats}
onMatchWith={handleMatchWith}
/>
{/* Chat Area */}
<div className="flex-1 flex flex-col">

View File

@@ -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) && (
<div className="border-t bg-blue-50 p-4">
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-3">
<Video className="w-6 h-6 text-primary-600" />
<div>
<p className="font-medium text-gray-900">{selectedFile?.name}</p>
<p className="text-sm text-gray-500">
{selectedFile && `${(selectedFile.size / 1024 / 1024).toFixed(2)} MB`}
</p>
</div>
</div>
{!isTransferring && (
<button
onClick={() => setSelectedFile(null)}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-5 h-5" />
</button>
)}
</div>
<FileTransferProgress
file={selectedFile}
progress={transferProgress}
isTransferring={isTransferring}
onCancel={handleCancelTransfer}
onSend={handleStartTransfer}
onRemoveFile={() => setSelectedFile(null)}
/>
{isTransferring ? (
<>
<div className="mb-2">
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>Transferring via WebRTC...</span>
<span>{transferProgress}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-primary-600 h-2 rounded-full transition-all duration-200"
style={{ width: `${transferProgress}%` }}
/>
</div>
</div>
<button
onClick={handleCancelTransfer}
className="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={handleStartTransfer}
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors flex items-center justify-center space-x-2"
>
<Upload className="w-4 h-4" />
<span>Send video (P2P)</span>
</button>
)}
</div>
</div>
)}
{/* Link Input Section */}
{showLinkInput && (
<div className="border-t bg-yellow-50 p-4">
<div className="bg-white rounded-lg p-4 shadow-sm">
<form onSubmit={handleSendLink} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Video link (Google Drive, Dropbox, etc.)
</label>
<input
type="url"
value={videoLink}
onChange={(e) => 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
/>
</div>
<div className="flex space-x-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
>
Send link
</button>
<button
type="button"
onClick={() => {
setShowLinkInput(false);
setVideoLink('');
}}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
<LinkShareInput
isOpen={showLinkInput}
link={videoLink}
onChange={(e) => setVideoLink(e.target.value)}
onSubmit={handleSendLink}
onCancel={() => {
setShowLinkInput(false);
setVideoLink('');
}}
/>
{/* Message Input & Actions */}
<div className="border-t p-4 bg-gray-50">