From a5a1296a4eedfce5f2b3c644f5b23ba4b86dab5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Sun, 23 Nov 2025 18:50:35 +0100 Subject: [PATCH] feat(frontend): add recording matching UI Add frontend components for auto-matching recording partners: - RecordingTab component with suggestions list and opt-out toggle - Tab navigation in EventChatPage (Chat, Uczestnicy, Nagrywanie) - Matching configuration in EventDetailsPage (deadline, run matching) - matchingAPI functions in api.js - Return registrationDeadline and matchingRunAt in GET /events/:slug/details UI allows users to: - View who will record their heats - View heats they need to record - Accept/reject suggestions - Opt-out from being a recorder - Set registration deadline (admin) - Manually trigger matching (admin) --- backend/src/routes/events.js | 2 + .../components/events/ParticipantsSidebar.jsx | 5 +- .../components/recordings/RecordingTab.jsx | 374 ++++++++++++++++++ frontend/src/pages/EventChatPage.jsx | 142 +++++-- frontend/src/pages/EventDetailsPage.jsx | 125 +++++- frontend/src/services/api.js | 44 +++ 6 files changed, 657 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/recordings/RecordingTab.jsx diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index be932ff..9e471d6 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -379,6 +379,8 @@ router.get('/:slug/details', authenticate, async (req, res, next) => { startDate: event.startDate, endDate: event.endDate, description: event.description, + registrationDeadline: event.registrationDeadline, + matchingRunAt: event.matchingRunAt, }, checkin: { token: checkinToken.token, diff --git a/frontend/src/components/events/ParticipantsSidebar.jsx b/frontend/src/components/events/ParticipantsSidebar.jsx index ce2924c..90ebc9a 100644 --- a/frontend/src/components/events/ParticipantsSidebar.jsx +++ b/frontend/src/components/events/ParticipantsSidebar.jsx @@ -36,13 +36,14 @@ const ParticipantsSidebar = ({ hideMyHeats = false, onHideMyHeatsChange, onMatchWith, - className = '' + className = '', + fullWidth = false }) => { const participantCount = users.length; const onlineCount = activeUsers.length; return ( -
+
{/* Header */}

diff --git a/frontend/src/components/recordings/RecordingTab.jsx b/frontend/src/components/recordings/RecordingTab.jsx new file mode 100644 index 0000000..44f809a --- /dev/null +++ b/frontend/src/components/recordings/RecordingTab.jsx @@ -0,0 +1,374 @@ +import { useState, useEffect } from 'react'; +import { Video, VideoOff, Clock, CheckCircle, XCircle, AlertTriangle, RefreshCw } from 'lucide-react'; +import { matchingAPI } from '../../services/api'; +import Avatar from '../common/Avatar'; + +/** + * RecordingTab - Main component for managing recording partnerships + * Shows suggestions for who will record user's heats and who user needs to record + */ +const RecordingTab = ({ slug, event, myHeats }) => { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [suggestions, setSuggestions] = useState(null); + const [recorderOptOut, setRecorderOptOut] = useState(false); + const [updatingOptOut, setUpdatingOptOut] = useState(false); + const [runningMatching, setRunningMatching] = useState(false); + + // Load suggestions on mount + useEffect(() => { + loadSuggestions(); + }, [slug]); + + const loadSuggestions = async () => { + try { + setLoading(true); + setError(null); + const data = await matchingAPI.getSuggestions(slug); + setSuggestions(data); + } catch (err) { + console.error('Failed to load suggestions:', err); + setError('Nie udalo sie zaladowac sugestii'); + } finally { + setLoading(false); + } + }; + + const handleOptOutChange = async (newValue) => { + try { + setUpdatingOptOut(true); + await matchingAPI.setRecorderOptOut(slug, newValue); + setRecorderOptOut(newValue); + } catch (err) { + console.error('Failed to update opt-out:', err); + } finally { + setUpdatingOptOut(false); + } + }; + + const handleUpdateStatus = async (suggestionId, status) => { + try { + await matchingAPI.updateSuggestionStatus(slug, suggestionId, status); + // Reload suggestions + await loadSuggestions(); + } catch (err) { + console.error('Failed to update suggestion status:', err); + } + }; + + const handleRunMatching = async () => { + try { + setRunningMatching(true); + await matchingAPI.runMatching(slug); + await loadSuggestions(); + } catch (err) { + console.error('Failed to run matching:', err); + } finally { + setRunningMatching(false); + } + }; + + // Calculate countdown to matching + const getCountdown = () => { + if (!event?.registrationDeadline) return null; + const deadline = new Date(event.registrationDeadline); + const now = new Date(); + const diff = deadline.getTime() - now.getTime(); + + if (diff <= 0) return null; + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + return { hours, minutes }; + }; + + if (loading) { + return ( +
+
+ +

Ladowanie...

+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + const countdown = getCountdown(); + const toBeRecorded = suggestions?.toBeRecorded || []; + const toRecord = suggestions?.toRecord || []; + const matchingRunAt = suggestions?.matchingRunAt; + + return ( +
+ {/* Header with info */} +
+
+
+

+ System automatycznie dobiera osoby do wzajemnego nagrywania wystepow. +

+
+ + {/* Countdown or Status */} + {countdown ? ( +
+
+ +
+

+ Matching uruchomi sie za: {countdown.hours}h {countdown.minutes}min +

+

+ Po zamknieciu zapisow system automatycznie dobierze partnerow do nagrywania. +

+
+
+
+ ) : matchingRunAt ? ( +
+
+
+ +
+

Matching zakonczony

+

+ Ostatnie uruchomienie: {new Date(matchingRunAt).toLocaleString('pl-PL')} +

+
+
+ +
+
+ ) : ( +
+
+
+ +

Matching nie zostal jeszcze uruchomiony

+
+ +
+
+ )} + + {/* My heats to be recorded */} +
+

+

+ + {toBeRecorded.length === 0 ? ( +
+ {myHeats.length === 0 + ? 'Nie masz zadeklarowanych heatow' + : 'Brak sugestii - uruchom matching' + } +
+ ) : ( +
+ {toBeRecorded.map((suggestion) => ( + handleUpdateStatus(suggestion.id, 'accepted')} + onReject={() => handleUpdateStatus(suggestion.id, 'rejected')} + /> + ))} +
+ )} +
+ + {/* Heats I need to record */} +
+

+ + Nagrywam innych ({toRecord.length}) +

+ + {toRecord.length === 0 ? ( +
+ Nie masz przypisanych heatow do nagrywania +
+ ) : ( +
+ {toRecord.map((suggestion) => ( + handleUpdateStatus(suggestion.id, 'accepted')} + onReject={() => handleUpdateStatus(suggestion.id, 'rejected')} + /> + ))} +
+ )} +
+ + {/* Opt-out toggle */} +
+ +

+ Jesli zaznaczysz, spadniesz na koniec kolejki przy przydzielaniu partnerow. +

+
+
+ ); +}; + +/** + * SuggestionCard - Single suggestion item + */ +const SuggestionCard = ({ suggestion, type, onAccept, onReject }) => { + const heat = suggestion.heat; + const recorder = suggestion.recorder; + const dancer = heat?.user; + const status = suggestion.status; + + // Format heat info + const heatInfo = heat + ? `${heat.division?.abbreviation || '?'} ${heat.competitionType?.abbreviation || '?'} H${heat.heatNumber}` + : 'Unknown heat'; + + const person = type === 'toBeRecorded' ? recorder : dancer; + const personLabel = type === 'toBeRecorded' ? 'Nagrywa Cie:' : 'Nagrywasz:'; + + // Status badge + const getStatusBadge = () => { + switch (status) { + case 'accepted': + return ( + + + Zaakceptowano + + ); + case 'rejected': + return ( + + + Odrzucono + + ); + case 'not_found': + return ( + + + Nie znaleziono + + ); + default: + return null; + } + }; + + // No recorder found + if (status === 'not_found') { + return ( +
+
+
+ +
+
+

{heatInfo}

+

Nie znaleziono dostepnego partnera

+
+
+ {getStatusBadge()} +
+ ); + } + + return ( +
+
+ {person ? ( + + ) : ( +
+ )} +
+

{heatInfo}

+

+ {personLabel} @{person?.username || '?'} + {person?.city && ( + ({person.city}) + )} +

+
+
+ +
+ {status === 'pending' ? ( + <> + + + + ) : ( + getStatusBadge() + )} +
+
+ ); +}; + +export default RecordingTab; diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 11aff8b..2a54cb1 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -2,7 +2,7 @@ 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 } from 'lucide-react'; +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'; @@ -14,6 +14,7 @@ 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(); @@ -50,6 +51,9 @@ const EventChatPage = () => { 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' }); }; @@ -397,40 +401,116 @@ const EventChatPage = () => { /> )} -
- !shouldHideUser(u.userId))} - activeUsers={activeUsers} - userHeats={userHeats} - userCompetitorNumbers={userCompetitorNumbers} - myHeats={myHeats} - hideMyHeats={hideMyHeats} - onHideMyHeatsChange={setHideMyHeats} - onMatchWith={handleMatchWith} - /> + {/* Tab Navigation */} +
+ +
- {/* Chat Area */} -
- +
+ {/* Chat Tab */} + {activeTab === 'chat' && ( +
+ {/* Sidebar - visible only on chat tab on larger screens */} +
+ !shouldHideUser(u.userId))} + activeUsers={activeUsers} + userHeats={userHeats} + userCompetitorNumbers={userCompetitorNumbers} + myHeats={myHeats} + hideMyHeats={hideMyHeats} + onHideMyHeatsChange={setHideMyHeats} + onMatchWith={handleMatchWith} + /> +
- {/* Message Input */} -
- setNewMessage(e.target.value)} - onSubmit={handleSendMessage} - disabled={!isConnected} - placeholder="Write a message..." + {/* Chat Area */} +
+ + + {/* Message Input */} +
+ setNewMessage(e.target.value)} + onSubmit={handleSendMessage} + disabled={!isConnected} + placeholder="Write a message..." + /> +
+
+
+ )} + + {/* Participants Tab */} + {activeTab === 'participants' && ( +
+ !shouldHideUser(u.userId))} + activeUsers={activeUsers} + userHeats={userHeats} + userCompetitorNumbers={userCompetitorNumbers} + myHeats={myHeats} + hideMyHeats={hideMyHeats} + onHideMyHeatsChange={setHideMyHeats} + onMatchWith={handleMatchWith} + fullWidth={true} />
-
+ )} + + {/* Recording Tab */} + {activeTab === 'recording' && ( + + )}
{/* Leave Event Button */} diff --git a/frontend/src/pages/EventDetailsPage.jsx b/frontend/src/pages/EventDetailsPage.jsx index 2a176f1..861721d 100644 --- a/frontend/src/pages/EventDetailsPage.jsx +++ b/frontend/src/pages/EventDetailsPage.jsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { useParams, Link } from 'react-router-dom'; import { QRCodeSVG } from 'qrcode.react'; -import { Copy, Check, Users, Calendar, MapPin, QrCode } from 'lucide-react'; +import { Copy, Check, Users, Calendar, MapPin, QrCode, Video, Clock, Save, RefreshCw } from 'lucide-react'; import Layout from '../components/layout/Layout'; -import { eventsAPI } from '../services/api'; +import { eventsAPI, matchingAPI } from '../services/api'; export default function EventDetailsPage() { const { slug } = useParams(); @@ -12,6 +12,11 @@ export default function EventDetailsPage() { const [error, setError] = useState(''); const [copied, setCopied] = useState(false); + // Registration deadline state + const [deadlineInput, setDeadlineInput] = useState(''); + const [savingDeadline, setSavingDeadline] = useState(false); + const [runningMatching, setRunningMatching] = useState(false); + useEffect(() => { fetchEventDetails(); }, [slug]); @@ -21,6 +26,11 @@ export default function EventDetailsPage() { setLoading(true); const response = await eventsAPI.getDetails(slug); setEventDetails(response.data); + // Initialize deadline input + if (response.data?.event?.registrationDeadline) { + const deadline = new Date(response.data.event.registrationDeadline); + setDeadlineInput(deadline.toISOString().slice(0, 16)); // Format for datetime-local + } } catch (err) { console.error('Error loading event details:', err); setError(err.message || 'Failed to load event details'); @@ -29,6 +39,34 @@ export default function EventDetailsPage() { } }; + const handleSaveDeadline = async () => { + try { + setSavingDeadline(true); + const deadline = deadlineInput ? new Date(deadlineInput).toISOString() : null; + await matchingAPI.setRegistrationDeadline(slug, deadline); + await fetchEventDetails(); // Refresh data + } catch (err) { + console.error('Failed to save deadline:', err); + alert('Nie udalo sie zapisac deadline'); + } finally { + setSavingDeadline(false); + } + }; + + const handleRunMatching = async () => { + try { + setRunningMatching(true); + const result = await matchingAPI.runMatching(slug); + alert(`Matching zakonczony! Dopasowano: ${result.matched}, Nie znaleziono: ${result.notFound}`); + await fetchEventDetails(); // Refresh data + } catch (err) { + console.error('Failed to run matching:', err); + alert('Nie udalo sie uruchomic matchingu'); + } finally { + setRunningMatching(false); + } + }; + const copyToClipboard = async () => { try { await navigator.clipboard.writeText(eventDetails.checkin.url); @@ -210,6 +248,89 @@ export default function EventDetailsPage() {
+ {/* Auto-Matching Configuration */} +
+

+

+ +
+ {/* Registration Deadline */} +
+ +

+ Matching uruchomi sie 30 min po tym terminie +

+
+ setDeadlineInput(e.target.value)} + className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" + /> + +
+ {event.registrationDeadline && ( +

+ Aktualny deadline: {new Date(event.registrationDeadline).toLocaleString('pl-PL')} +

+ )} +
+ + {/* Matching Status & Run */} +
+ + {event.matchingRunAt ? ( +
+

+ Ostatnie uruchomienie: {new Date(event.matchingRunAt).toLocaleString('pl-PL')} +

+
+ ) : ( +
+

+ Matching nie byl jeszcze uruchomiony +

+
+ )} + +
+
+
+ {/* Action Buttons */}