diff --git a/package-lock.json b/package-lock.json index 52c3005..9cf3b89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@types/socket.io": "^3.0.1", + "framer-motion": "^12.23.12", "ioredis": "^5.7.0", "next": "^15.0.0", "react": "^19.0.0", @@ -5933,6 +5934,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.12.tgz", + "integrity": "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.12", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -7518,6 +7546,21 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion-dom": { + "version": "12.23.12", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.12.tgz", + "integrity": "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", diff --git a/package.json b/package.json index f4858d7..d801bdf 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@types/socket.io": "^3.0.1", + "framer-motion": "^12.23.12", "ioredis": "^5.7.0", "next": "^15.0.0", "react": "^19.0.0", diff --git a/src/app/room/[id]/page.tsx b/src/app/room/[id]/page.tsx index ba44b3d..00bc723 100644 --- a/src/app/room/[id]/page.tsx +++ b/src/app/room/[id]/page.tsx @@ -1,9 +1,7 @@ +import { RoomLayout } from '../components/RoomLayout' + export default async function RoomPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params - return ( -
-

Room: {id}

-

Room functionality will be implemented here.

-
- ) + + return } diff --git a/src/app/room/components/Instructions.tsx b/src/app/room/components/Instructions.tsx new file mode 100644 index 0000000..af55c44 --- /dev/null +++ b/src/app/room/components/Instructions.tsx @@ -0,0 +1,112 @@ +'use client' + +import { useState } from 'react' +import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes' + +interface InstructionsProps { + currentUserRole: ParticipantRoleEnum + className?: string +} + +export function Instructions({ currentUserRole, className = '' }: InstructionsProps) { + const [isCollapsed, setIsCollapsed] = useState(false) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + const organizerInstructions = [ + 'Add participants using the form in the participants panel', + 'Click "Spin the Wheel" and set a presentation time (1-60 minutes)', + 'The wheel will randomly select a presenter and start the timer', + 'Mark presentations as finished or let the timer expire', + 'Enable/disable participants as needed during the session', + 'Share the room URL with others to have them join as guests', + ] + + const guestInstructions = [ + 'Wait for the organizer to add you to the participants list', + 'Watch the wheel spin and see who gets selected to present', + 'Follow along with the presentation timer', + 'Your name will be highlighted in the participants list', + 'All updates happen in real-time across all connected users', + ] + + const instructions = isOrganizer ? organizerInstructions : guestInstructions + + return ( +
+ + + {!isCollapsed && ( +
+
+
    + {instructions.map((instruction, index) => ( +
  • + + {index + 1} + + {instruction} +
  • + ))} +
+ + {/* Additional Info */} +
+

+ 💡 Pro Tips +

+
    + {isOrganizer ? ( + <> +
  • + • Participants must be in "Queued" status to be selected by the + wheel +
  • +
  • • Use "Disable" to temporarily remove someone from selection
  • +
  • • The timer shows yellow (last 2 min) and red (last 30 sec) warnings
  • +
  • • You can stop the timer early if needed
  • + + ) : ( + <> +
  • • All changes sync in real-time - no need to refresh
  • +
  • • Your name will be highlighted when you're in the list
  • +
  • • The wheel uses random selection for fairness
  • +
  • • You can see the current presentation status and timer
  • + + )} +
+
+
+
+ )} +
+ ) +} diff --git a/src/app/room/components/ParticipantsList.tsx b/src/app/room/components/ParticipantsList.tsx new file mode 100644 index 0000000..6787fc9 --- /dev/null +++ b/src/app/room/components/ParticipantsList.tsx @@ -0,0 +1,286 @@ +'use client' + +import { useState } from 'react' +import { + ParticipantRoleEnum, + ParticipantStatusEnum, +} from '@/domain/room/value-objects/participant-attributes' + +interface Participant { + id: string + name: string + status: ParticipantStatusEnum + role: ParticipantRoleEnum + isCurrentUser?: boolean +} + +interface ParticipantsListProps { + participants: Participant[] + currentUserRole: ParticipantRoleEnum + currentUserId?: string + onAddParticipant?: (name: string) => void + onToggleParticipant?: (id: string, enable: boolean) => void + onMarkFinished?: (id: string) => void + isLoading?: boolean +} + +export function ParticipantsList({ + participants, + currentUserRole, + currentUserId, + onAddParticipant, + onToggleParticipant, + onMarkFinished, + isLoading = false, +}: ParticipantsListProps) { + const [newParticipantName, setNewParticipantName] = useState('') + const [isAddingParticipant, setIsAddingParticipant] = useState(false) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + const handleAddParticipant = async (e: React.FormEvent) => { + e.preventDefault() + if (!newParticipantName.trim() || !onAddParticipant) return + + setIsAddingParticipant(true) + try { + await onAddParticipant(newParticipantName.trim()) + setNewParticipantName('') + } catch (error) { + console.error('Failed to add participant:', error) + } finally { + setIsAddingParticipant(false) + } + } + + const getStatusColor = (status: ParticipantStatusEnum) => { + switch (status) { + case ParticipantStatusEnum.QUEUED: + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300' + case ParticipantStatusEnum.ACTIVE: + return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' + case ParticipantStatusEnum.FINISHED: + return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' + case ParticipantStatusEnum.DISABLED: + return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300' + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' + } + } + + const getStatusText = (status: ParticipantStatusEnum) => { + switch (status) { + case ParticipantStatusEnum.QUEUED: + return 'Queued' + case ParticipantStatusEnum.ACTIVE: + return 'Presenting' + case ParticipantStatusEnum.FINISHED: + return 'Finished' + case ParticipantStatusEnum.DISABLED: + return 'Disabled' + default: + return status + } + } + + return ( +
+
+

+ Participants ({participants.length}) +

+ {isOrganizer && ( + Organizer View + )} +
+ + {/* Add Participant Form (Organizer Only) */} + {isOrganizer && onAddParticipant && ( +
+
+ + setNewParticipantName(e.target.value)} + placeholder='Enter participant name' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md + shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 + dark:bg-gray-700 dark:text-white placeholder-gray-400 dark:placeholder-gray-500' + disabled={isAddingParticipant || isLoading} + required + /> +
+ +
+ )} + + {/* Participants List */} +
+ {participants.length === 0 ? ( +
+

No participants yet

+ {isOrganizer ? ( +

Add the first participant to get started

+ ) : ( +

Waiting for organizer to add participants

+ )} +
+ ) : ( + participants.map(participant => { + const isCurrentUser = participant.id === currentUserId + return ( +
+
+
+

+ {participant.name} + {isCurrentUser && (You)} + {participant.role === ParticipantRoleEnum.ORGANIZER && ( + + Organizer + + )} +

+
+
+ +
+ {/* Status Badge */} + + {getStatusText(participant.status)} + + + {/* Action Buttons (Organizer Only) */} + {isOrganizer && participant.role !== ParticipantRoleEnum.ORGANIZER && ( +
+ {/* Toggle Enable/Disable */} + {onToggleParticipant && ( + + )} + + {/* Mark as Finished */} + {onMarkFinished && participant.status === ParticipantStatusEnum.ACTIVE && ( + + )} +
+ )} +
+
+ ) + }) + )} +
+ + {isLoading && ( +
+ + + + + Syncing... +
+ )} +
+ ) +} diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx new file mode 100644 index 0000000..51f2563 --- /dev/null +++ b/src/app/room/components/RoomLayout.tsx @@ -0,0 +1,466 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + ParticipantRoleEnum, + ParticipantStatusEnum, +} from '@/domain/room/value-objects/participant-attributes' +import { RoleDetectionService } from '../utils/role-detection' +import { RoomTitle } from './RoomTitle' +import { ParticipantsList } from './ParticipantsList' +import { Wheel } from './Wheel' +import { TimerPanel } from './TimerPanel' +import { Instructions } from './Instructions' + +interface Participant { + id: string + name: string + status: ParticipantStatusEnum + role: ParticipantRoleEnum +} + +interface RoomData { + id: string + name?: string + participants: Participant[] + currentPresenterId?: string + organizerId: string + timerStartTime?: Date + timerDurationMinutes?: number + timerPausedTime?: Date + timerRemainingSeconds?: number +} + +interface RoomLayoutProps { + roomId: string + initialData?: RoomData +} + +export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { + const [userRole, setUserRole] = useState(ParticipantRoleEnum.GUEST) + const [currentUserId, setCurrentUserId] = useState('') + const [roomData, setRoomData] = useState(initialData || null) + const [isLoading, setIsLoading] = useState(false) + const [isSpinning, setIsSpinning] = useState(false) + const [error, setError] = useState(null) + const [lastWinnerState, setLastWinner] = useState<{ id: string; name: string } | null>(null) + + // Initialize role detection + useEffect(() => { + // For MVP, first visitor to a room becomes organizer + // In production, this would be handled by proper authentication + const isOrganizer = RoleDetectionService.isOrganizer(roomId) + + if (isOrganizer) { + setUserRole(ParticipantRoleEnum.ORGANIZER) + } else { + // Mark as organizer if no cookie exists (first visitor) + RoleDetectionService.setAsOrganizer(roomId) + setUserRole(ParticipantRoleEnum.ORGANIZER) + } + + // Generate a temporary user ID for this session + // In real implementation, this would come from authentication or session management + setCurrentUserId(crypto.randomUUID()) + }, [roomId]) + + // Mock data for development - replace with real Socket.IO integration + useEffect(() => { + if (!roomData) { + // Simulate loading room data + setRoomData({ + id: roomId, + name: `Demo Room`, + participants: [ + { + id: '1', + name: 'Alice Johnson', + status: ParticipantStatusEnum.QUEUED, + role: ParticipantRoleEnum.ORGANIZER, + }, + { + id: '2', + name: 'Bob Smith', + status: ParticipantStatusEnum.QUEUED, + role: ParticipantRoleEnum.GUEST, + }, + { + id: '3', + name: 'Carol Williams', + status: ParticipantStatusEnum.FINISHED, + role: ParticipantRoleEnum.GUEST, + }, + { + id: '4', + name: 'David Wilson', + status: ParticipantStatusEnum.QUEUED, + role: ParticipantRoleEnum.GUEST, + }, + { + id: '5', + name: 'Eve Brown', + status: ParticipantStatusEnum.QUEUED, + role: ParticipantRoleEnum.GUEST, + }, + { + id: '6', + name: 'Frank Miller', + status: ParticipantStatusEnum.DISABLED, + role: ParticipantRoleEnum.GUEST, + }, + ], + organizerId: '1', + }) + } + }, [roomId, roomData]) + + const handleAddParticipant = async (name: string) => { + setIsLoading(true) + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 500)) + + const newParticipant: Participant = { + id: crypto.randomUUID(), + name, + status: ParticipantStatusEnum.QUEUED, + role: ParticipantRoleEnum.GUEST, + } + + setRoomData(prev => + prev + ? { + ...prev, + participants: [...prev.participants, newParticipant], + } + : null + ) + } catch (error) { + console.error('Failed to add participant:', error) + setError('Failed to add participant') + } finally { + setIsLoading(false) + } + } + + const handleToggleParticipant = async (id: string, enable: boolean) => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + setRoomData(prev => + prev + ? { + ...prev, + participants: prev.participants.map(p => + p.id === id + ? { + ...p, + status: enable + ? ParticipantStatusEnum.QUEUED + : ParticipantStatusEnum.DISABLED, + } + : p + ), + } + : null + ) + } catch (error) { + console.error('Failed to toggle participant:', error) + setError('Failed to update participant') + } finally { + setIsLoading(false) + } + } + + const handleMarkFinished = async (id: string) => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + setRoomData(prev => + prev + ? { + ...prev, + participants: prev.participants.map(p => + p.id === id ? { ...p, status: ParticipantStatusEnum.FINISHED } : p + ), + currentPresenterId: undefined, + timerStartTime: undefined, + } + : null + ) + } catch (error) { + console.error('Failed to mark participant as finished:', error) + setError('Failed to mark as finished') + } finally { + setIsLoading(false) + } + } + + const handleSpinStart = () => { + setIsSpinning(true) + setIsLoading(true) + } + + const handleSpinResult = async (winner: { id: string; name: string }) => { + try { + setRoomData(prev => + prev + ? { + ...prev, + participants: prev.participants.map(p => + p.id === winner.id ? { ...p, status: ParticipantStatusEnum.ACTIVE } : p + ), + currentPresenterId: winner.id, + timerStartTime: undefined, // Don't start timer automatically + timerDurationMinutes: undefined, + } + : null + ) + setLastWinner({ id: winner.id, name: winner.name }) + } catch (error) { + console.error('Failed to process wheel result:', error) + setError('Failed to process wheel result') + } finally { + setIsSpinning(false) + setIsLoading(false) + } + } + + const handleSpinError = (error: Error) => { + console.error('Wheel spin error:', error) + setError(error.message) + setIsSpinning(false) + setIsLoading(false) + } + + const handleStartTimer = async (timeInMinutes: number) => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + setRoomData(prev => + prev + ? { + ...prev, + timerStartTime: new Date(), + timerDurationMinutes: timeInMinutes, + } + : null + ) + } catch (error) { + console.error('Failed to start timer:', error) + setError('Failed to start timer') + } finally { + setIsLoading(false) + } + } + + const handlePauseTimer = async () => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + // Calculate remaining time when pausing + const now = new Date() + const currentRoomData = roomData + if (currentRoomData?.timerStartTime && currentRoomData?.timerDurationMinutes) { + const elapsed = Math.floor( + (now.getTime() - currentRoomData.timerStartTime.getTime()) / 1000 + ) + const totalSeconds = currentRoomData.timerDurationMinutes * 60 + const remainingSeconds = Math.max(0, totalSeconds - elapsed) + + setRoomData(prev => + prev + ? { + ...prev, + timerStartTime: undefined, // Stop the timer + timerPausedTime: now, // Mark when it was paused + timerRemainingSeconds: remainingSeconds, // Save remaining time + } + : null + ) + } + } catch (error) { + console.error('Failed to pause timer:', error) + setError('Failed to pause timer') + } finally { + setIsLoading(false) + } + } + + const handleContinueTimer = async () => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + // Resume timer with remaining time + const now = new Date() + const currentRoomData = roomData + if (currentRoomData?.timerRemainingSeconds !== undefined) { + // Calculate new duration in minutes from remaining seconds + const remainingMinutes = currentRoomData.timerRemainingSeconds / 60 + + setRoomData(prev => + prev + ? { + ...prev, + timerStartTime: now, + timerDurationMinutes: remainingMinutes, + timerPausedTime: undefined, + timerRemainingSeconds: undefined, + } + : null + ) + } + } catch (error) { + console.error('Failed to continue timer:', error) + setError('Failed to continue timer') + } finally { + setIsLoading(false) + } + } + + if (!roomData) { + return ( +
+
+ + + + +

Loading room...

+
+
+ ) + } + + const currentPresenter = roomData.currentPresenterId + ? roomData.participants.find(p => p.id === roomData.currentPresenterId) + : null + + const lastWinner = + lastWinnerState || + (currentPresenter + ? { + id: currentPresenter.id, + name: currentPresenter.name, + } + : null) + + return ( +
+
+ {/* Error Message */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Room Title - Full Width */} + + + {/* Main Content - Responsive Grid */} +
+ {/* Desktop: Left Column - Participants List (1/3) */} +
+ +
+ + {/* Desktop: Center Column - Wheel (2/3) */} +
+ +
+ + {/* Mobile: Top / Desktop: Right Column - Timer Panel (1/3) */} +
+ +
+
+ + {/* Instructions - Full Width */} + +
+
+ ) +} diff --git a/src/app/room/components/RoomTitle.tsx b/src/app/room/components/RoomTitle.tsx new file mode 100644 index 0000000..a3da07f --- /dev/null +++ b/src/app/room/components/RoomTitle.tsx @@ -0,0 +1,134 @@ +'use client' + +import { useState } from 'react' +import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes' + +interface RoomTitleProps { + roomId: string + roomName?: string + currentUserRole: ParticipantRoleEnum +} + +export function RoomTitle({ roomId, roomName, currentUserRole }: RoomTitleProps) { + const [showCopied, setShowCopied] = useState(false) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + const handleCopyUrl = async () => { + try { + const url = `${window.location.origin}/room/${roomId}` + await navigator.clipboard.writeText(url) + setShowCopied(true) + setTimeout(() => setShowCopied(false), 2000) + } catch (error) { + console.error('Failed to copy URL:', error) + // Fallback for browsers that don't support clipboard API + const textArea = document.createElement('textarea') + textArea.value = `${window.location.origin}/room/${roomId}` + document.body.appendChild(textArea) + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + setShowCopied(true) + setTimeout(() => setShowCopied(false), 2000) + } + } + + const displayName = roomName || `Room ${roomId.substring(0, 8)}...` + + return ( +
+
+ {/* Room Title */} +
+

+ {displayName} +

+

Room ID: {roomId}

+
+ + {isOrganizer ? 'Organizer' : 'Guest'} + +
+
+ + {/* Copy URL Button (Organizer Only) */} + {isOrganizer && ( +
+ +
+ )} +
+ + {/* Instructions for guests */} + {!isOrganizer && ( +
+

+ 💡 You're in guest mode. You can view the wheel and timer in real-time, but only + the organizer can control the session. +

+
+ )} + + {/* Instructions for organizer */} + {isOrganizer && ( +
+

+ 🎯 You're the organizer! Add participants, spin the wheel, and manage the session. + Share the invite URL with others to have them join as guests. +

+
+ )} +
+ ) +} diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx new file mode 100644 index 0000000..74d8728 --- /dev/null +++ b/src/app/room/components/TimerPanel.tsx @@ -0,0 +1,374 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + ParticipantRoleEnum, + ParticipantStatusEnum, +} from '@/domain/room/value-objects/participant-attributes' + +interface CurrentPresenter { + id: string + name: string + status: ParticipantStatusEnum +} + +interface TimerPanelProps { + currentPresenter: CurrentPresenter | null + currentUserRole: ParticipantRoleEnum + onMarkFinished?: (id: string) => void + onPauseTimer?: () => void + onContinueTimer?: () => void + onStartTimer?: (timeInMinutes: number) => void + timerStartTime?: Date | null + timerDurationMinutes?: number + timerPausedTime?: Date | null + timerRemainingSeconds?: number + isLoading?: boolean +} + +export function TimerPanel({ + currentPresenter, + currentUserRole, + onMarkFinished, + onPauseTimer, + onContinueTimer, + onStartTimer, + timerStartTime, + timerDurationMinutes = 10, + timerPausedTime, + timerRemainingSeconds, + isLoading = false, +}: TimerPanelProps) { + const [timeRemaining, setTimeRemaining] = useState(0) + const [isActive, setIsActive] = useState(false) + const [isPaused, setIsPaused] = useState(false) + const [selectedTime, setSelectedTime] = useState(10) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + // Calculate time remaining + useEffect(() => { + if (!currentPresenter) { + setTimeRemaining(0) + setIsActive(false) + setIsPaused(false) + return + } + + // Check if timer is paused + if (timerPausedTime && !timerStartTime) { + setIsPaused(true) + setIsActive(false) + // Set the remaining time from room data if available + if (timerRemainingSeconds !== undefined) { + setTimeRemaining(timerRemainingSeconds) + } + return + } + + // Check if timer is not started + if (!timerStartTime) { + setTimeRemaining(0) + setIsActive(false) + setIsPaused(false) + return + } + + setIsActive(true) + setIsPaused(false) + + const updateTimer = () => { + const now = new Date() + const elapsed = Math.floor((now.getTime() - timerStartTime.getTime()) / 1000) + const totalSeconds = timerDurationMinutes * 60 + const remaining = Math.max(0, totalSeconds - elapsed) + + setTimeRemaining(remaining) + + if (remaining === 0) { + setIsActive(false) + // Timer ended - could trigger auto-finish here + } + } + + updateTimer() + const interval = setInterval(updateTimer, 1000) + + return () => clearInterval(interval) + }, [ + timerStartTime, + timerDurationMinutes, + timerPausedTime, + timerRemainingSeconds, + currentPresenter, + ]) + + const formatTime = (seconds: number): string => { + const minutes = Math.floor(seconds / 60) + const remainingSeconds = seconds % 60 + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}` + } + + const getTimerColor = (): string => { + if (isPaused) return 'text-orange-500' // Paused + if (!isActive || timeRemaining === 0) return 'text-gray-500 dark:text-gray-400' + + if (timeRemaining <= 30) return 'text-red-500' // Critical: last 30 seconds + if (timeRemaining <= 120) return 'text-yellow-500' // Warning: last 2 minutes + return 'text-green-500' // Normal + } + + const getTimerBgColor = (): string => { + if (isPaused) return 'bg-orange-50 dark:bg-orange-900/20' // Paused + if (!isActive || timeRemaining === 0) return 'bg-gray-100 dark:bg-gray-700' + + if (timeRemaining <= 30) return 'bg-red-50 dark:bg-red-900/20' // Critical + if (timeRemaining <= 120) return 'bg-yellow-50 dark:bg-yellow-900/20' // Warning + return 'bg-green-50 dark:bg-green-900/20' // Normal + } + + const handleStartTimer = () => { + if (onStartTimer && selectedTime > 0) { + onStartTimer(selectedTime) + } + } + + return ( +
+

Current Session

+ + {currentPresenter ? ( +
+ {/* Current Presenter */} +
+

+ Now Presenting +

+
+

+ {currentPresenter.name} +

+ + {currentPresenter.status === ParticipantStatusEnum.ACTIVE + ? 'Presenting' + : 'Finished'} + +
+
+ + {/* Timer Display */} + {(isActive || isPaused) && ( +
+

+ Time Remaining +

+
+
+ {formatTime(timeRemaining)} +
+ {isPaused && ( +
+ ⏸️ Timer Paused +
+ )} + {!isPaused && timeRemaining <= 30 && timeRemaining > 0 && ( +
+ Time running out! +
+ )} + {!isPaused && timeRemaining === 0 && ( +
+ Time's up! +
+ )} +
+
+ )} + + {/* Timer Controls (Organizer Only) */} + {isOrganizer && + !isActive && + !isPaused && + currentPresenter.status === ParticipantStatusEnum.ACTIVE && + onStartTimer && ( +
+
+

+ Presentation Duration +

+
+ + + setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1))) + } + className='flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 + rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 + dark:bg-gray-700 dark:text-white text-center' + /> +
+ + {/* Quick Choose Section */} +
+

+ Quick Choose: +

+
+ {[5, 10, 15, 30].map(minutes => ( + + ))} +
+
+
+ + +
+ )} + + {/* Control Buttons (Organizer Only) */} + {isOrganizer && ( +
+ {currentPresenter.status === ParticipantStatusEnum.ACTIVE && onMarkFinished && ( + + )} + + {/* Show Pause button when timer is active */} + {isActive && onPauseTimer && ( + + )} + + {/* Show Continue button when timer is paused */} + {isPaused && onContinueTimer && ( + + )} +
+ )} +
+ ) : ( + /* No Current Presenter */ +
+
+ + + +
+

+ No Active Presentation +

+

+ {isOrganizer + ? 'Spin the wheel to select the next presenter' + : 'Waiting for organizer to spin the wheel'} +

+
+ )} + + {/* Session Instructions */} +
+

Session Info

+
    +
  • + • Session duration: {timerStartTime ? timerDurationMinutes : selectedTime} minutes +
  • +
  • • Yellow warning: Last 2 minutes
  • +
  • • Red critical: Last 30 seconds
  • + {isOrganizer &&
  • • Click "Mark as Finished" to end early
  • } +
+
+ + {isLoading && ( +
+ + + + + Updating... +
+ )} +
+ ) +} diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx new file mode 100644 index 0000000..ffc6590 --- /dev/null +++ b/src/app/room/components/Wheel.tsx @@ -0,0 +1,368 @@ +'use client' + +import React, { useRef, useState, useEffect, useMemo } from 'react' +import { + ParticipantRoleEnum, + ParticipantStatusEnum, +} from '@/domain/room/value-objects/participant-attributes' + +// Types +export type WheelItem = { + id: string + label: string + color?: string + meta?: Record +} + +interface Participant { + id: string + name: string + status: ParticipantStatusEnum + role: ParticipantRoleEnum +} + +export type WheelProps = { + participants: Participant[] + currentUserRole: ParticipantRoleEnum + isSpinning?: boolean + onSpinStart?: () => void + onResult?: (winner: { id: string; name: string }) => void + onError?: (e: Error) => void + lastWinner?: { + id: string + name: string + } | null + className?: string +} + +const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3) + +// Predefined unique colors for wheel sectors +const WHEEL_COLORS = [ + '#FF6B6B', // Red + '#4ECDC4', // Turquoise + '#45B7D1', // Blue + '#FFA07A', // Salmon + '#98D8C8', // Mint + '#F7DC6F', // Yellow + '#BB8FCE', // Violet + '#85C1E9', // Light Blue + '#F8C471', // Orange + '#82E0AA', // Green + '#F1948A', // Pink + '#AED6F1', // Sky Blue + '#D7BDE2', // Lavender + '#A3E4D7', // Aquamarine + '#FAD7A0', // Peach + '#F9E79F', // Cream + '#D5A6BD', // Dusty Rose + '#A9CCE3', // Sky Blue + '#ABEBC6', // Light Green + '#F5B7B1', // Coral +] + +// Generate color ensuring uniqueness within the wheel +const getUniqueWheelColor = ( + participantId: string, + index: number, + totalParticipants: number +): string => { + // If participants are fewer than predefined colors, use them in order + if (totalParticipants <= WHEEL_COLORS.length) { + return WHEEL_COLORS[index] + } + + // If more participants, generate colors evenly distributed on the color wheel + const hueStep = 360 / totalParticipants + const hue = Math.round(index * hueStep) + + // Add a small variation based on ID for determinism + let hash = 0 + for (let i = 0; i < participantId.length; i++) { + hash = participantId.charCodeAt(i) + ((hash << 5) - hash) + } + const hueOffset = (Math.abs(hash) % 30) - 15 // ±15 градусов вариации + const finalHue = (hue + hueOffset + 360) % 360 + + // Use high saturation and medium brightness for good contrast + const saturation = 65 + (Math.abs(hash) % 20) // 65-84% + const lightness = 55 + (Math.abs(hash >> 8) % 15) // 55-69% + + return `hsl(${finalHue}, ${saturation}%, ${lightness}%)` +} + +export const Wheel: React.FC = ({ + participants, + currentUserRole, + isSpinning = false, + onSpinStart, + onResult, + onError, + lastWinner, + className = '', +}) => { + // Ensure participants is array and get eligible ones + const safeParticipants = Array.isArray(participants) ? participants : [] + const eligibleParticipants = safeParticipants.filter( + p => p.status === ParticipantStatusEnum.QUEUED || p.status === ParticipantStatusEnum.ACTIVE + ) + + // Convert participants to wheel items + const items = useMemo( + () => + eligibleParticipants.map(p => ({ + id: p.id, + label: p.name, + })), + [eligibleParticipants] + ) + + // Props validation + useEffect(() => { + try { + if (items.length < 2 || items.length > 20) { + if (items.length === 1) { + // Allow single participant for display purposes + return + } + throw new Error('Participants count must be between 2 and 20') + } + const ids = items.map(i => i.id) + if (new Set(ids).size !== ids.length) { + throw new Error('Duplicate participant id found') + } + items.forEach(item => { + if (!item.label || item.label.length > 20) { + throw new Error(`Label missing or exceeds 20 chars: ${item.id}`) + } + }) + } catch (err) { + onError?.(err as Error) + } + }, [items, onError]) + + const [currentAngle, setCurrentAngle] = useState(0) + const [spinning, setSpinning] = useState(false) + const rafRef = useRef(null) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + const canSpin = isOrganizer && items.length >= 1 && !spinning && !isSpinning + + // Reduced motion preference + const prefersReduced = useMemo(() => { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches + } + return false + }, []) + + // Angle per sector + const anglePerItem = items.length > 0 ? 360 / items.length : 360 + + // Sectors with colors + const sectors = useMemo( + () => + items.map((item, index) => ({ + ...item, + color: getUniqueWheelColor(item.id, index, items.length), + })), + [items] + ) + + // Convert polar to cartesian coordinates + const polarToCartesian = ( + cx: number, + cy: number, + r: number, + deg: number + ): { x: number; y: number } => { + const rad = ((deg - 90) * Math.PI) / 180 + return { + x: cx + r * Math.cos(rad), + y: cy + r * Math.sin(rad), + } + } + + // Render sectors + const renderSectors = (): React.ReactElement[] => { + if (sectors.length === 0) { + return [ + , + ] + } + + return sectors.map((item, idx) => { + const startAngle = idx * anglePerItem + const endAngle = startAngle + anglePerItem + const largeArcFlag = anglePerItem > 180 ? 1 : 0 + const start = polarToCartesian(200, 200, 180, startAngle) + const end = polarToCartesian(200, 200, 180, endAngle) + const pathData = `M200,200 L${start.x},${start.y} A180,180 0 ${largeArcFlag} 1 ${end.x},${end.y} Z` + + // Calculate text position + const textAngle = startAngle + anglePerItem / 2 + const textPos = polarToCartesian(200, 200, 120, textAngle) + + return ( + + + + {item.label.length > 10 ? item.label.substring(0, 10) + '...' : item.label} + + + ) + }) + } + + // Обработчик спина + const spin = (): void => { + if (spinning || !canSpin || items.length === 0) return + try { + onSpinStart?.() + setSpinning(true) + + // Выбор победителя + const buf = new Uint32Array(1) + window.crypto.getRandomValues(buf) + const random = buf[0] / 0xffffffff + const idx = Math.floor(random * items.length) + const winner = items[idx] + + // Целевой угол (центр сектора) + const sectorStart = idx * anglePerItem + const targetAngle = sectorStart + anglePerItem / 2 + + // Параметры анимации + const rotations = prefersReduced ? 1 : Math.floor(Math.random() * 5) + 2 + const duration = prefersReduced ? 500 : Math.floor(Math.random() * 3000) + 2000 + const totalRotation = rotations * 360 + (360 - targetAngle) + const startTime = performance.now() + + // Анимационный цикл + const animate = (now: number) => { + const elapsed = now - startTime + const t = Math.min(elapsed / duration, 1) + const eased = easeOutCubic(t) + setCurrentAngle(eased * totalRotation) + if (t < 1) { + rafRef.current = requestAnimationFrame(animate) + } else { + setSpinning(false) + onResult?.({ + id: winner.id, + name: winner.label, + }) + } + } + + rafRef.current = requestAnimationFrame(animate) + } catch (err) { + onError?.(err as Error) + setSpinning(false) + } + } + + // Очистка при размонтировании + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + } + } + }, []) + + return ( +
+
+

+ Presenter Selection Wheel +

+ + {/* Wheel Container */} +
+ + {renderSectors()} + {/* Pointer */} + + {/* Center circle */} + + + + {/* Spinning overlay */} + {(spinning || isSpinning) && ( +
+
+ Spinning... +
+
+ )} +
+ + {/* Last Winner Display */} + {lastWinner && !spinning && !isSpinning && ( +
+

+ Last Selected: {lastWinner.name} +

+
+ )} + + {/* Spin Button */} +
+ {isOrganizer ? ( + + ) : ( +
+

Only the organizer can spin the wheel

+
+ )} + + {items.length === 0 && ( +

+ Add participants to enable wheel spinning +

+ )} +
+
+
+ ) +} + +// Add default export for test compatibility +export default Wheel diff --git a/src/app/room/utils/role-detection.ts b/src/app/room/utils/role-detection.ts new file mode 100644 index 0000000..8246e67 --- /dev/null +++ b/src/app/room/utils/role-detection.ts @@ -0,0 +1,111 @@ +'use client' + +import { ParticipantRoleEnum } from '@/domain/room/value-objects/participant-attributes' + +const ROLE_COOKIE_KEY = 'wheel-user-role' +const ROOM_ORGANIZER_KEY = 'wheel-room-organizer' + +/** + * Cookie utilities for role detection in room + */ +export class RoleDetectionService { + /** + * Check if user is organizer for a specific room + */ + static isOrganizer(roomId: string): boolean { + if (typeof document === 'undefined') return false + + const organizerRooms = this.getOrganizerRooms() + return organizerRooms.includes(roomId) + } + + /** + * Mark user as organizer for a room (first visitor) + */ + static setAsOrganizer(roomId: string): void { + if (typeof document === 'undefined') return + + const organizerRooms = this.getOrganizerRooms() + if (!organizerRooms.includes(roomId)) { + organizerRooms.push(roomId) + this.setOrganizerRooms(organizerRooms) + } + + // Also set current role + this.setCurrentRole(ParticipantRoleEnum.ORGANIZER) + } + + /** + * Set user as guest for current session + */ + static setAsGuest(): void { + if (typeof document === 'undefined') return + this.setCurrentRole(ParticipantRoleEnum.GUEST) + } + + /** + * Get current user role + */ + static getCurrentRole(): ParticipantRoleEnum { + if (typeof document === 'undefined') return ParticipantRoleEnum.GUEST + + const role = this.getCookie(ROLE_COOKIE_KEY) + return role === ParticipantRoleEnum.ORGANIZER + ? ParticipantRoleEnum.ORGANIZER + : ParticipantRoleEnum.GUEST + } + + /** + * Clear role information (for testing/debugging) + */ + static clearRoles(): void { + if (typeof document === 'undefined') return + this.deleteCookie(ROLE_COOKIE_KEY) + this.deleteCookie(ROOM_ORGANIZER_KEY) + } + + // Private helper methods + private static getOrganizerRooms(): string[] { + const rooms = this.getCookie(ROOM_ORGANIZER_KEY) + if (!rooms) return [] + try { + return JSON.parse(rooms) + } catch { + return [] + } + } + + private static setOrganizerRooms(rooms: string[]): void { + this.setCookie(ROOM_ORGANIZER_KEY, JSON.stringify(rooms), 7) // 7 days + } + + private static setCurrentRole(role: ParticipantRoleEnum): void { + this.setCookie(ROLE_COOKIE_KEY, role, 1) // 1 day + } + + private static getCookie(name: string): string | null { + if (typeof document === 'undefined') return null + + const nameEQ = name + '=' + const ca = document.cookie.split(';') + for (let i = 0; i < ca.length; i++) { + let c = ca[i] + while (c.charAt(0) === ' ') c = c.substring(1, c.length) + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) + } + return null + } + + private static setCookie(name: string, value: string, days: number): void { + if (typeof document === 'undefined') return + + const expires = new Date() + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000) + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` + } + + private static deleteCookie(name: string): void { + if (typeof document === 'undefined') return + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;path=/` + } +}