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:
106
frontend/src/components/events/ParticipantsSidebar.jsx
Normal file
106
frontend/src/components/events/ParticipantsSidebar.jsx
Normal file
@@ -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
|
||||||
|
* <ParticipantsSidebar
|
||||||
|
* users={filteredUsers}
|
||||||
|
* activeUsers={activeUsers}
|
||||||
|
* userHeats={userHeats}
|
||||||
|
* myHeats={myHeats}
|
||||||
|
* hideMyHeats={hideMyHeats}
|
||||||
|
* onHideMyHeatsChange={setHideMyHeats}
|
||||||
|
* onMatchWith={handleMatchWith}
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
const ParticipantsSidebar = ({
|
||||||
|
users = [],
|
||||||
|
activeUsers = [],
|
||||||
|
userHeats = new Map(),
|
||||||
|
myHeats = [],
|
||||||
|
hideMyHeats = false,
|
||||||
|
onHideMyHeatsChange,
|
||||||
|
onMatchWith,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const participantCount = users.length;
|
||||||
|
const onlineCount = activeUsers.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-64 border-r bg-gray-50 p-4 overflow-y-auto ${className}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-2">
|
||||||
|
Participants ({participantCount})
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
{onlineCount} 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) => onHideMyHeatsChange?.(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>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{participantCount === 0 && (
|
||||||
|
<p className="text-sm text-gray-500">No other participants</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{users.map((displayUser) => {
|
||||||
|
const thisUserHeats = userHeats.get(displayUser.userId) || [];
|
||||||
|
const hasHeats = thisUserHeats.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserListItem
|
||||||
|
key={displayUser.userId}
|
||||||
|
user={displayUser}
|
||||||
|
heats={thisUserHeats}
|
||||||
|
showHeats={true}
|
||||||
|
actionButton={
|
||||||
|
<button
|
||||||
|
onClick={() => 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'}
|
||||||
|
>
|
||||||
|
<UserPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantsSidebar;
|
||||||
65
frontend/src/components/heats/HeatBadges.jsx
Normal file
65
frontend/src/components/heats/HeatBadges.jsx
Normal file
@@ -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
|
||||||
|
* <HeatBadges heats={userHeats} maxVisible={3} />
|
||||||
|
* // 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 (
|
||||||
|
<div className={`flex flex-wrap gap-1 ${className}`}>
|
||||||
|
{visibleHeats.map((heat, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-800 rounded font-mono"
|
||||||
|
title={`${heat.competitionType?.name || ''} ${heat.division?.name || ''} Heat ${heat.heatNumber} (${heat.role || ''})`}
|
||||||
|
>
|
||||||
|
{formatHeat(heat)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 bg-gray-200 text-gray-700 rounded font-mono"
|
||||||
|
title={`${remainingCount} more heat${remainingCount > 1 ? 's' : ''}`}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeatBadges;
|
||||||
93
frontend/src/components/users/UserListItem.jsx
Normal file
93
frontend/src/components/users/UserListItem.jsx
Normal file
@@ -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
|
||||||
|
* <UserListItem
|
||||||
|
* user={displayUser}
|
||||||
|
* heats={userHeats.get(userId)}
|
||||||
|
* actionButton={
|
||||||
|
* <button onClick={() => handleMatch(userId)}>
|
||||||
|
* <UserPlus />
|
||||||
|
* </button>
|
||||||
|
* }
|
||||||
|
* />
|
||||||
|
*/
|
||||||
|
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 ? (
|
||||||
|
<Link
|
||||||
|
to={`/profile/${user.username}`}
|
||||||
|
className={`${usernameClasses} hover:text-primary-600`}
|
||||||
|
>
|
||||||
|
{user.username}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className={usernameClasses}>{user.username}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-between p-2 hover:bg-gray-100 rounded-lg ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar}
|
||||||
|
username={user.username}
|
||||||
|
size={32}
|
||||||
|
status={isOnline ? 'online' : 'offline'}
|
||||||
|
title={user.username}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{usernameContent}
|
||||||
|
{/* Full name (optional) */}
|
||||||
|
{(user.firstName || user.lastName) && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{/* Heat Badges */}
|
||||||
|
{showHeats && hasHeats && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<HeatBadges heats={heats} maxVisible={3} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{actionButton && (
|
||||||
|
<div className="flex-shrink-0 ml-2">
|
||||||
|
{actionButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserListItem;
|
||||||
99
frontend/src/components/webrtc/FileTransferProgress.jsx
Normal file
99
frontend/src/components/webrtc/FileTransferProgress.jsx
Normal file
@@ -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
|
||||||
|
* <FileTransferProgress
|
||||||
|
* file={selectedFile}
|
||||||
|
* progress={transferProgress}
|
||||||
|
* isTransferring={isTransferring}
|
||||||
|
* onCancel={handleCancelTransfer}
|
||||||
|
* onSend={handleStartTransfer}
|
||||||
|
* onRemoveFile={() => 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 (
|
||||||
|
<div className="border-t bg-blue-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||||
|
{/* File Info Header */}
|
||||||
|
<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 flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{file.name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{fileSizeMB} MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isTransferring && onRemoveFile && (
|
||||||
|
<button
|
||||||
|
onClick={onRemoveFile}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors flex-shrink-0"
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar & Actions */}
|
||||||
|
{isTransferring ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||||
|
<span>Transferring via WebRTC...</span>
|
||||||
|
<span>{progress}%</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: `${progress}%` }}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={progress}
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="w-full px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={onSend}
|
||||||
|
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 font-medium"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>Send video (P2P)</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileTransferProgress;
|
||||||
78
frontend/src/components/webrtc/LinkShareInput.jsx
Normal file
78
frontend/src/components/webrtc/LinkShareInput.jsx
Normal file
@@ -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
|
||||||
|
* <LinkShareInput
|
||||||
|
* isOpen={showLinkInput}
|
||||||
|
* link={videoLink}
|
||||||
|
* onChange={(e) => 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 (
|
||||||
|
<div className="border-t bg-yellow-50 p-4">
|
||||||
|
<div className="bg-white rounded-lg p-4 shadow-sm">
|
||||||
|
<form onSubmit={handleSubmit} 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={link}
|
||||||
|
onChange={onChange}
|
||||||
|
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
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</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 font-medium"
|
||||||
|
>
|
||||||
|
Send link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkShareInput;
|
||||||
@@ -12,6 +12,7 @@ import ChatInput from '../components/chat/ChatInput';
|
|||||||
import ConfirmationModal from '../components/modals/ConfirmationModal';
|
import ConfirmationModal from '../components/modals/ConfirmationModal';
|
||||||
import Modal from '../components/modals/Modal';
|
import Modal from '../components/modals/Modal';
|
||||||
import useEventChat from '../hooks/useEventChat';
|
import useEventChat from '../hooks/useEventChat';
|
||||||
|
import ParticipantsSidebar from '../components/events/ParticipantsSidebar';
|
||||||
|
|
||||||
const EventChatPage = () => {
|
const EventChatPage = () => {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -191,18 +192,6 @@ const EventChatPage = () => {
|
|||||||
// Heats will be updated via Socket.IO heats_updated event
|
// 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) => {
|
const shouldHideUser = (userId) => {
|
||||||
if (!hideMyHeats || myHeats.length === 0) return false;
|
if (!hideMyHeats || myHeats.length === 0) return false;
|
||||||
|
|
||||||
@@ -403,97 +392,15 @@ const EventChatPage = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex h-[calc(100vh-280px)]">
|
<div className="flex h-[calc(100vh-280px)]">
|
||||||
{/* Participants Sidebar */}
|
<ParticipantsSidebar
|
||||||
<div className="w-64 border-r bg-gray-50 p-4 overflow-y-auto">
|
users={getAllDisplayUsers().filter(u => !shouldHideUser(u.userId))}
|
||||||
<div className="mb-4">
|
activeUsers={activeUsers}
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">
|
userHeats={userHeats}
|
||||||
Participants ({getAllDisplayUsers().filter(u => !shouldHideUser(u.userId)).length})
|
myHeats={myHeats}
|
||||||
</h3>
|
hideMyHeats={hideMyHeats}
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
onHideMyHeatsChange={setHideMyHeats}
|
||||||
{activeUsers.length} online
|
onMatchWith={handleMatchWith}
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Chat Area */}
|
{/* Chat Area */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import Avatar from '../components/common/Avatar';
|
|||||||
import ChatMessageList from '../components/chat/ChatMessageList';
|
import ChatMessageList from '../components/chat/ChatMessageList';
|
||||||
import ChatInput from '../components/chat/ChatInput';
|
import ChatInput from '../components/chat/ChatInput';
|
||||||
import useMatchChat from '../hooks/useMatchChat';
|
import useMatchChat from '../hooks/useMatchChat';
|
||||||
|
import FileTransferProgress from '../components/webrtc/FileTransferProgress';
|
||||||
|
import LinkShareInput from '../components/webrtc/LinkShareInput';
|
||||||
|
|
||||||
const MatchChatPage = () => {
|
const MatchChatPage = () => {
|
||||||
const { slug } = useParams();
|
const { slug } = useParams();
|
||||||
@@ -287,104 +289,25 @@ const MatchChatPage = () => {
|
|||||||
messagesEndRef={messagesEndRef}
|
messagesEndRef={messagesEndRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video Transfer Section */}
|
<FileTransferProgress
|
||||||
{(selectedFile || isTransferring) && (
|
file={selectedFile}
|
||||||
<div className="border-t bg-blue-50 p-4">
|
progress={transferProgress}
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
isTransferring={isTransferring}
|
||||||
<div className="flex items-center justify-between mb-3">
|
onCancel={handleCancelTransfer}
|
||||||
<div className="flex items-center space-x-3">
|
onSend={handleStartTransfer}
|
||||||
<Video className="w-6 h-6 text-primary-600" />
|
onRemoveFile={() => setSelectedFile(null)}
|
||||||
<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>
|
|
||||||
|
|
||||||
{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 */}
|
<LinkShareInput
|
||||||
{showLinkInput && (
|
isOpen={showLinkInput}
|
||||||
<div className="border-t bg-yellow-50 p-4">
|
link={videoLink}
|
||||||
<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)}
|
onChange={(e) => setVideoLink(e.target.value)}
|
||||||
placeholder="https://drive.google.com/..."
|
onSubmit={handleSendLink}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
onCancel={() => {
|
||||||
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);
|
setShowLinkInput(false);
|
||||||
setVideoLink('');
|
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Message Input & Actions */}
|
{/* Message Input & Actions */}
|
||||||
<div className="border-t p-4 bg-gray-50">
|
<div className="border-t p-4 bg-gray-50">
|
||||||
|
|||||||
Reference in New Issue
Block a user