From f3ab25663ab68bc4949d21dbefad2118bef3830f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:31:31 +0000 Subject: [PATCH 1/6] Initial plan From 131cdd799f51dd17f1331aab4a9d4d6dbefcf2e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:54:30 +0000 Subject: [PATCH 2/6] feat(room): implement complete room layout with role-based UI and wheel functionality - Add role detection service with cookie-based persistence - Implement responsive 3-column layout (desktop) and single-column stack (mobile) - Create ParticipantsList component with add/disable/finish controls for organizer - Build animated Wheel component with framer-motion spin animation - Add TimerPanel with countdown, color coding, and control buttons - Include RoomTitle with copy URL button for organizer - Implement collapsible Instructions component with role-specific content - Add RoomLayout orchestrator with mock data integration - Support organizer vs guest role switching via cookies - All components styled with Tailwind CSS following design requirements Co-authored-by: underscorekadji <3449713+underscorekadji@users.noreply.github.com> --- package-lock.json | 43 +++ package.json | 1 + src/app/room/[id]/page.tsx | 10 +- src/app/room/components/Instructions.tsx | 112 ++++++ src/app/room/components/ParticipantsList.tsx | 286 +++++++++++++++ src/app/room/components/RoomLayout.tsx | 366 +++++++++++++++++++ src/app/room/components/RoomTitle.tsx | 134 +++++++ src/app/room/components/TimerPanel.tsx | 241 ++++++++++++ src/app/room/components/Wheel.tsx | 286 +++++++++++++++ src/app/room/utils/role-detection.ts | 111 ++++++ 10 files changed, 1584 insertions(+), 6 deletions(-) create mode 100644 src/app/room/components/Instructions.tsx create mode 100644 src/app/room/components/ParticipantsList.tsx create mode 100644 src/app/room/components/RoomLayout.tsx create mode 100644 src/app/room/components/RoomTitle.tsx create mode 100644 src/app/room/components/TimerPanel.tsx create mode 100644 src/app/room/components/Wheel.tsx create mode 100644 src/app/room/utils/role-detection.ts 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..1c422a2 --- /dev/null +++ b/src/app/room/components/RoomLayout.tsx @@ -0,0 +1,366 @@ +'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 +} + +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) + + // 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) { + // Mark as organizer if no cookie exists (first visitor) + RoleDetectionService.setAsOrganizer(roomId) + setUserRole(ParticipantRoleEnum.ORGANIZER) + } else { + 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, + }, + ], + 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 handleSpin = async (timeInMinutes: number) => { + setIsSpinning(true) + setIsLoading(true) + try { + // Simulate wheel spin duration + await new Promise(resolve => setTimeout(resolve, 3000)) + + const eligibleParticipants = + roomData?.participants.filter(p => p.status === ParticipantStatusEnum.QUEUED) || [] + + if (eligibleParticipants.length > 0) { + const selectedParticipant = + eligibleParticipants[Math.floor(Math.random() * eligibleParticipants.length)] + + setRoomData(prev => + prev + ? { + ...prev, + participants: prev.participants.map(p => + p.id === selectedParticipant.id + ? { ...p, status: ParticipantStatusEnum.ACTIVE } + : p + ), + currentPresenterId: selectedParticipant.id, + timerStartTime: new Date(), + timerDurationMinutes: timeInMinutes, + } + : null + ) + } + } catch (error) { + console.error('Failed to spin wheel:', error) + setError('Failed to spin wheel') + } finally { + setIsSpinning(false) + setIsLoading(false) + } + } + + const handleStopTimer = async () => { + setIsLoading(true) + try { + await new Promise(resolve => setTimeout(resolve, 300)) + + setRoomData(prev => + prev + ? { + ...prev, + timerStartTime: undefined, + } + : null + ) + } catch (error) { + console.error('Failed to stop timer:', error) + setError('Failed to stop timer') + } finally { + setIsLoading(false) + } + } + + if (!roomData) { + return ( +
+
+ + + + +

Loading room...

+
+
+ ) + } + + const currentPresenter = roomData.currentPresenterId + ? roomData.participants.find(p => p.id === roomData.currentPresenterId) + : null + + const lastWinner = 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..e165736 --- /dev/null +++ b/src/app/room/components/TimerPanel.tsx @@ -0,0 +1,241 @@ +'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 + onStopTimer?: () => void + timerStartTime?: Date | null + timerDurationMinutes?: number + isLoading?: boolean +} + +export function TimerPanel({ + currentPresenter, + currentUserRole, + onMarkFinished, + onStopTimer, + timerStartTime, + timerDurationMinutes = 10, + isLoading = false, +}: TimerPanelProps) { + const [timeRemaining, setTimeRemaining] = useState(0) + const [isActive, setIsActive] = useState(false) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + // Calculate time remaining + useEffect(() => { + if (!timerStartTime || !currentPresenter) { + setTimeRemaining(0) + setIsActive(false) + return + } + + setIsActive(true) + 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, 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 (!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 (!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 + } + + return ( +
+

Current Session

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

+ Now Presenting +

+
+

+ {currentPresenter.name} +

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

+ Time Remaining +

+
+
+ {formatTime(timeRemaining)} +
+ {timeRemaining <= 30 && timeRemaining > 0 && ( +
+ Time running out! +
+ )} + {timeRemaining === 0 && ( +
+ Time's up! +
+ )} +
+
+ )} + + {/* Control Buttons (Organizer Only) */} + {isOrganizer && ( +
+ {currentPresenter.status === ParticipantStatusEnum.ACTIVE && onMarkFinished && ( + + )} + + {isActive && onStopTimer && ( + + )} +
+ )} +
+ ) : ( + /* 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

+
    +
  • • Timer duration: {timerDurationMinutes} 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..dc4292d --- /dev/null +++ b/src/app/room/components/Wheel.tsx @@ -0,0 +1,286 @@ +'use client' + +import { useState } from 'react' +import { motion } from 'framer-motion' +import { + ParticipantRoleEnum, + ParticipantStatusEnum, +} from '@/domain/room/value-objects/participant-attributes' + +interface Participant { + id: string + name: string + status: ParticipantStatusEnum + role: ParticipantRoleEnum +} + +interface WheelProps { + participants: Participant[] + currentUserRole: ParticipantRoleEnum + isSpinning?: boolean + onSpin?: (timeInMinutes: number) => void + lastWinner?: { + id: string + name: string + } | null +} + +const WHEEL_COLORS = [ + '#FF6B6B', + '#4ECDC4', + '#45B7D1', + '#FFA07A', + '#98D8C8', + '#F7DC6F', + '#BB8FCE', + '#85C1E9', + '#F8C471', + '#82E0AA', + '#F1948A', + '#AED6F1', +] + +export function Wheel({ + participants, + currentUserRole, + isSpinning = false, + onSpin, + lastWinner, +}: WheelProps) { + const [rotation, setRotation] = useState(0) + const [showTimeModal, setShowTimeModal] = useState(false) + const [selectedTime, setSelectedTime] = useState(10) + + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + + // Get eligible participants (queued only) + const eligibleParticipants = participants.filter( + p => p.status === ParticipantStatusEnum.QUEUED || p.status === ParticipantStatusEnum.ACTIVE + ) + + const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning + + const handleSpinClick = () => { + if (!canSpin) return + setShowTimeModal(true) + } + + const handleTimeSubmit = () => { + if (onSpin && selectedTime > 0) { + // Calculate random rotation (multiple full rotations + random position) + const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations + const randomAngle = Math.random() * 360 + const newRotation = rotation + baseRotations * 360 + randomAngle + + setRotation(newRotation) + onSpin(selectedTime) + } + setShowTimeModal(false) + } + + // Calculate sector size and positions + const sectorCount = Math.max(eligibleParticipants.length, 1) + const sectorAngle = 360 / sectorCount + + const renderWheelSectors = () => { + if (eligibleParticipants.length === 0) { + return + } + + return eligibleParticipants.map((participant, index) => { + const startAngle = index * sectorAngle - 90 // Start from top + const endAngle = (index + 1) * sectorAngle - 90 + const color = WHEEL_COLORS[index % WHEEL_COLORS.length] + + // Calculate path for sector + const startAngleRad = (startAngle * Math.PI) / 180 + const endAngleRad = (endAngle * Math.PI) / 180 + const largeArcFlag = sectorAngle > 180 ? 1 : 0 + + const x1 = 200 + 150 * Math.cos(startAngleRad) + const y1 = 200 + 150 * Math.sin(startAngleRad) + const x2 = 200 + 150 * Math.cos(endAngleRad) + const y2 = 200 + 150 * Math.sin(endAngleRad) + + const pathData = [ + `M 200 200`, + `L ${x1} ${y1}`, + `A 150 150 0 ${largeArcFlag} 1 ${x2} ${y2}`, + `Z`, + ].join(' ') + + // Calculate text position + const textAngle = startAngle + sectorAngle / 2 + const textAngleRad = (textAngle * Math.PI) / 180 + const textX = 200 + 100 * Math.cos(textAngleRad) + const textY = 200 + 100 * Math.sin(textAngleRad) + + return ( + + + + {participant.name.length > 10 + ? participant.name.substring(0, 10) + '...' + : participant.name} + + + ) + }) + } + + return ( +
+
+

+ Presenter Selection Wheel +

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

+ Last Selected: {lastWinner.name} +

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

Only the organizer can spin the wheel

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

+ Add participants to enable wheel spinning +

+ )} +
+
+ + {/* Time Selection Modal */} + {showTimeModal && ( +
+
+

+ Set Presentation Time +

+ +
+ + + setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1))) + } + className='w-full 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' + autoFocus + /> +
+ +
+ + +
+
+
+ )} +
+ ) +} 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=/` + } +} From 3226be7dcbfc6b36da3015391dd5d439864600e9 Mon Sep 17 00:00:00 2001 From: Alexander Belenkov Date: Wed, 6 Aug 2025 13:35:20 +0300 Subject: [PATCH 3/6] feat(timer): add timer functionality with start and stop controls for organizers --- src/app/room/components/RoomLayout.tsx | 31 ++++++++++- src/app/room/components/TimerPanel.tsx | 75 +++++++++++++++++++++++++ src/app/room/components/Wheel.tsx | 77 +++----------------------- 3 files changed, 111 insertions(+), 72 deletions(-) diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx index 1c422a2..275f279 100644 --- a/src/app/room/components/RoomLayout.tsx +++ b/src/app/room/components/RoomLayout.tsx @@ -177,7 +177,7 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { } } - const handleSpin = async (timeInMinutes: number) => { + const handleSpin = async () => { setIsSpinning(true) setIsLoading(true) try { @@ -201,8 +201,8 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { : p ), currentPresenterId: selectedParticipant.id, - timerStartTime: new Date(), - timerDurationMinutes: timeInMinutes, + timerStartTime: undefined, // Don't start timer automatically + timerDurationMinutes: undefined, } : null ) @@ -216,6 +216,28 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { } } + 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 handleStopTimer = async () => { setIsLoading(true) try { @@ -351,6 +373,9 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { userRole === ParticipantRoleEnum.ORGANIZER ? handleMarkFinished : undefined } onStopTimer={userRole === ParticipantRoleEnum.ORGANIZER ? handleStopTimer : undefined} + onStartTimer={ + userRole === ParticipantRoleEnum.ORGANIZER ? handleStartTimer : undefined + } timerStartTime={roomData.timerStartTime} timerDurationMinutes={roomData.timerDurationMinutes} isLoading={isLoading} diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx index e165736..5f09d6e 100644 --- a/src/app/room/components/TimerPanel.tsx +++ b/src/app/room/components/TimerPanel.tsx @@ -17,6 +17,7 @@ interface TimerPanelProps { currentUserRole: ParticipantRoleEnum onMarkFinished?: (id: string) => void onStopTimer?: () => void + onStartTimer?: (timeInMinutes: number) => void timerStartTime?: Date | null timerDurationMinutes?: number isLoading?: boolean @@ -27,12 +28,14 @@ export function TimerPanel({ currentUserRole, onMarkFinished, onStopTimer, + onStartTimer, timerStartTime, timerDurationMinutes = 10, isLoading = false, }: TimerPanelProps) { const [timeRemaining, setTimeRemaining] = useState(0) const [isActive, setIsActive] = useState(false) + const [selectedTime, setSelectedTime] = useState(10) const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER @@ -87,6 +90,12 @@ export function TimerPanel({ return 'bg-green-50 dark:bg-green-900/20' // Normal } + const handleStartTimer = () => { + if (onStartTimer && selectedTime > 0) { + onStartTimer(selectedTime) + } + } + return (

Current Session

@@ -143,6 +152,72 @@ export function TimerPanel({
)} + {/* Timer Controls (Organizer Only) */} + {isOrganizer && + !isActive && + 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 && (
diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx index dc4292d..af7a9eb 100644 --- a/src/app/room/components/Wheel.tsx +++ b/src/app/room/components/Wheel.tsx @@ -18,7 +18,7 @@ interface WheelProps { participants: Participant[] currentUserRole: ParticipantRoleEnum isSpinning?: boolean - onSpin?: (timeInMinutes: number) => void + onSpin?: () => void lastWinner?: { id: string name: string @@ -48,8 +48,6 @@ export function Wheel({ lastWinner, }: WheelProps) { const [rotation, setRotation] = useState(0) - const [showTimeModal, setShowTimeModal] = useState(false) - const [selectedTime, setSelectedTime] = useState(10) const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER @@ -61,21 +59,15 @@ export function Wheel({ const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning const handleSpinClick = () => { - if (!canSpin) return - setShowTimeModal(true) - } + if (!canSpin || !onSpin) return - const handleTimeSubmit = () => { - if (onSpin && selectedTime > 0) { - // Calculate random rotation (multiple full rotations + random position) - const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations - const randomAngle = Math.random() * 360 - const newRotation = rotation + baseRotations * 360 + randomAngle + // Calculate random rotation (multiple full rotations + random position) + const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations + const randomAngle = Math.random() * 360 + const newRotation = rotation + baseRotations * 360 + randomAngle - setRotation(newRotation) - onSpin(selectedTime) - } - setShowTimeModal(false) + setRotation(newRotation) + onSpin() } // Calculate sector size and positions @@ -228,59 +220,6 @@ export function Wheel({ )}
- - {/* Time Selection Modal */} - {showTimeModal && ( -
-
-

- Set Presentation Time -

- -
- - - setSelectedTime(Math.max(1, Math.min(60, parseInt(e.target.value) || 1))) - } - className='w-full 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' - autoFocus - /> -
- -
- - -
-
-
- )} ) } From bbcf0a686c14b3b8598c19398b800ebd1debf385 Mon Sep 17 00:00:00 2001 From: Alexander Belenkov Date: Wed, 6 Aug 2025 15:50:26 +0300 Subject: [PATCH 4/6] feat(timer): enhance timer functionality with pause and continue features --- src/app/room/components/RoomLayout.tsx | 171 ++++++++++++++++++------- src/app/room/components/TimerPanel.tsx | 78 +++++++++-- 2 files changed, 190 insertions(+), 59 deletions(-) diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx index 275f279..0859e7a 100644 --- a/src/app/room/components/RoomLayout.tsx +++ b/src/app/room/components/RoomLayout.tsx @@ -27,6 +27,8 @@ interface RoomData { organizerId: string timerStartTime?: Date timerDurationMinutes?: number + timerPausedTime?: Date + timerRemainingSeconds?: number } interface RoomLayoutProps { @@ -41,6 +43,7 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { 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(() => { @@ -87,6 +90,24 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { 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', }) @@ -177,45 +198,43 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { } } - const handleSpin = async () => { + const handleSpinStart = () => { setIsSpinning(true) setIsLoading(true) - try { - // Simulate wheel spin duration - await new Promise(resolve => setTimeout(resolve, 3000)) - - const eligibleParticipants = - roomData?.participants.filter(p => p.status === ParticipantStatusEnum.QUEUED) || [] - - if (eligibleParticipants.length > 0) { - const selectedParticipant = - eligibleParticipants[Math.floor(Math.random() * eligibleParticipants.length)] + } - setRoomData(prev => - prev - ? { - ...prev, - participants: prev.participants.map(p => - p.id === selectedParticipant.id - ? { ...p, status: ParticipantStatusEnum.ACTIVE } - : p - ), - currentPresenterId: selectedParticipant.id, - timerStartTime: undefined, // Don't start timer automatically - timerDurationMinutes: undefined, - } - : null - ) - } + 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 spin wheel:', error) - setError('Failed to spin wheel') + 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 { @@ -238,22 +257,67 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { } } - const handleStopTimer = async () => { + const handlePauseTimer = async () => { setIsLoading(true) try { await new Promise(resolve => setTimeout(resolve, 300)) - setRoomData(prev => - prev - ? { - ...prev, - timerStartTime: undefined, - } - : null - ) + // 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 stop timer:', error) - setError('Failed to stop timer') + console.error('Failed to continue timer:', error) + setError('Failed to continue timer') } finally { setIsLoading(false) } @@ -296,12 +360,14 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { ? roomData.participants.find(p => p.id === roomData.currentPresenterId) : null - const lastWinner = currentPresenter - ? { - id: currentPresenter.id, - name: currentPresenter.name, - } - : null + const lastWinner = + lastWinnerState || + (currentPresenter + ? { + id: currentPresenter.id, + name: currentPresenter.name, + } + : null) return (
@@ -359,7 +425,9 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { participants={roomData.participants} currentUserRole={userRole} isSpinning={isSpinning} - onSpin={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpin : undefined} + onSpinStart={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpinStart : undefined} + onResult={userRole === ParticipantRoleEnum.ORGANIZER ? handleSpinResult : undefined} + onError={handleSpinError} lastWinner={lastWinner} />
@@ -372,12 +440,19 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { onMarkFinished={ userRole === ParticipantRoleEnum.ORGANIZER ? handleMarkFinished : undefined } - onStopTimer={userRole === ParticipantRoleEnum.ORGANIZER ? handleStopTimer : undefined} + onPauseTimer={ + userRole === ParticipantRoleEnum.ORGANIZER ? handlePauseTimer : undefined + } + onContinueTimer={ + userRole === ParticipantRoleEnum.ORGANIZER ? handleContinueTimer : undefined + } onStartTimer={ userRole === ParticipantRoleEnum.ORGANIZER ? handleStartTimer : undefined } timerStartTime={roomData.timerStartTime} timerDurationMinutes={roomData.timerDurationMinutes} + timerPausedTime={roomData.timerPausedTime} + timerRemainingSeconds={roomData.timerRemainingSeconds} isLoading={isLoading} /> diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx index 5f09d6e..5af2efe 100644 --- a/src/app/room/components/TimerPanel.tsx +++ b/src/app/room/components/TimerPanel.tsx @@ -16,10 +16,13 @@ interface TimerPanelProps { currentPresenter: CurrentPresenter | null currentUserRole: ParticipantRoleEnum onMarkFinished?: (id: string) => void - onStopTimer?: () => void + onPauseTimer?: () => void + onContinueTimer?: () => void onStartTimer?: (timeInMinutes: number) => void timerStartTime?: Date | null timerDurationMinutes?: number + timerPausedTime?: Date | null + timerRemainingSeconds?: number isLoading?: boolean } @@ -27,27 +30,53 @@ export function TimerPanel({ currentPresenter, currentUserRole, onMarkFinished, - onStopTimer, + 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 (!timerStartTime || !currentPresenter) { + 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) @@ -66,7 +95,13 @@ export function TimerPanel({ const interval = setInterval(updateTimer, 1000) return () => clearInterval(interval) - }, [timerStartTime, timerDurationMinutes, currentPresenter]) + }, [ + timerStartTime, + timerDurationMinutes, + timerPausedTime, + timerRemainingSeconds, + currentPresenter, + ]) const formatTime = (seconds: number): string => { const minutes = Math.floor(seconds / 60) @@ -75,6 +110,7 @@ export function TimerPanel({ } 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 @@ -83,6 +119,7 @@ export function TimerPanel({ } 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 @@ -127,7 +164,7 @@ export function TimerPanel({ {/* Timer Display */} - {isActive && ( + {(isActive || isPaused) && (

Time Remaining @@ -138,12 +175,17 @@ export function TimerPanel({ > {formatTime(timeRemaining)}

- {timeRemaining <= 30 && timeRemaining > 0 && ( + {isPaused && ( +
+ ⏸️ Timer Paused +
+ )} + {!isPaused && timeRemaining <= 30 && timeRemaining > 0 && (
Time running out!
)} - {timeRemaining === 0 && ( + {!isPaused && timeRemaining === 0 && (
Time's up!
@@ -155,6 +197,7 @@ export function TimerPanel({ {/* Timer Controls (Organizer Only) */} {isOrganizer && !isActive && + !isPaused && currentPresenter.status === ParticipantStatusEnum.ACTIVE && onStartTimer && (
@@ -232,14 +275,27 @@ export function TimerPanel({ )} - {isActive && onStopTimer && ( + {/* Show Pause button when timer is active */} + {isActive && onPauseTimer && ( + + )} + + {/* Show Continue button when timer is paused */} + {isPaused && onContinueTimer && ( )}
From a5596d35b7a88710cb8a1348136ff559c6d77185 Mon Sep 17 00:00:00 2001 From: Alexander Belenkov Date: Wed, 6 Aug 2025 15:53:51 +0300 Subject: [PATCH 5/6] feat(wheel): refactor Wheel component with enhanced color generation and improved spinning logic --- src/app/room/components/Wheel.tsx | 377 ++++++++++++++++++++---------- 1 file changed, 260 insertions(+), 117 deletions(-) diff --git a/src/app/room/components/Wheel.tsx b/src/app/room/components/Wheel.tsx index af7a9eb..ffc6590 100644 --- a/src/app/room/components/Wheel.tsx +++ b/src/app/room/components/Wheel.tsx @@ -1,12 +1,19 @@ 'use client' -import { useState } from 'react' -import { motion } from 'framer-motion' +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 @@ -14,165 +21,299 @@ interface Participant { role: ParticipantRoleEnum } -interface WheelProps { +export type WheelProps = { participants: Participant[] currentUserRole: ParticipantRoleEnum isSpinning?: boolean - onSpin?: () => void + 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', - '#4ECDC4', - '#45B7D1', - '#FFA07A', - '#98D8C8', - '#F7DC6F', - '#BB8FCE', - '#85C1E9', - '#F8C471', - '#82E0AA', - '#F1948A', - '#AED6F1', + '#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 ] -export function Wheel({ +// 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, - onSpin, + onSpinStart, + onResult, + onError, lastWinner, -}: WheelProps) { - const [rotation, setRotation] = useState(0) - - const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER - - // Get eligible participants (queued only) - const eligibleParticipants = participants.filter( + 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 ) - const canSpin = isOrganizer && eligibleParticipants.length > 0 && !isSpinning + // Convert participants to wheel items + const items = useMemo( + () => + eligibleParticipants.map(p => ({ + id: p.id, + label: p.name, + })), + [eligibleParticipants] + ) - const handleSpinClick = () => { - if (!canSpin || !onSpin) return + // 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]) - // Calculate random rotation (multiple full rotations + random position) - const baseRotations = 5 + Math.random() * 3 // 5-8 full rotations - const randomAngle = Math.random() * 360 - const newRotation = rotation + baseRotations * 360 + randomAngle + const [currentAngle, setCurrentAngle] = useState(0) + const [spinning, setSpinning] = useState(false) + const rafRef = useRef(null) - setRotation(newRotation) - onSpin() - } - - // Calculate sector size and positions - const sectorCount = Math.max(eligibleParticipants.length, 1) - const sectorAngle = 360 / sectorCount + const isOrganizer = currentUserRole === ParticipantRoleEnum.ORGANIZER + const canSpin = isOrganizer && items.length >= 1 && !spinning && !isSpinning - const renderWheelSectors = () => { - if (eligibleParticipants.length === 0) { - return + // Reduced motion preference + const prefersReduced = useMemo(() => { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches } + return false + }, []) - return eligibleParticipants.map((participant, index) => { - const startAngle = index * sectorAngle - 90 // Start from top - const endAngle = (index + 1) * sectorAngle - 90 - const color = WHEEL_COLORS[index % WHEEL_COLORS.length] + // Angle per sector + const anglePerItem = items.length > 0 ? 360 / items.length : 360 - // Calculate path for sector - const startAngleRad = (startAngle * Math.PI) / 180 - const endAngleRad = (endAngle * Math.PI) / 180 - const largeArcFlag = sectorAngle > 180 ? 1 : 0 + // Sectors with colors + const sectors = useMemo( + () => + items.map((item, index) => ({ + ...item, + color: getUniqueWheelColor(item.id, index, items.length), + })), + [items] + ) - const x1 = 200 + 150 * Math.cos(startAngleRad) - const y1 = 200 + 150 * Math.sin(startAngleRad) - const x2 = 200 + 150 * Math.cos(endAngleRad) - const y2 = 200 + 150 * Math.sin(endAngleRad) + // 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 [ + , + ] + } - const pathData = [ - `M 200 200`, - `L ${x1} ${y1}`, - `A 150 150 0 ${largeArcFlag} 1 ${x2} ${y2}`, - `Z`, - ].join(' ') + 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 + sectorAngle / 2 - const textAngleRad = (textAngle * Math.PI) / 180 - const textX = 200 + 100 * Math.cos(textAngleRad) - const textY = 200 + 100 * Math.sin(textAngleRad) + const textAngle = startAngle + anglePerItem / 2 + const textPos = polarToCartesian(200, 200, 120, textAngle) return ( - - + + - {participant.name.length > 10 - ? participant.name.substring(0, 10) + '...' - : participant.name} + {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 */} -
- {/* Wheel */} - - - {renderWheelSectors()} - {/* Center circle */} - - - - - {/* Pointer */} -
-
-
+
+ + {renderSectors()} + {/* Pointer */} + + {/* Center circle */} + + {/* Spinning overlay */} - {isSpinning && ( -
-
+ {(spinning || isSpinning) && ( +
+
Spinning...
@@ -180,7 +321,7 @@ export function Wheel({
{/* Last Winner Display */} - {lastWinner && !isSpinning && ( + {lastWinner && !spinning && !isSpinning && (

Last Selected: {lastWinner.name} @@ -192,18 +333,17 @@ export function Wheel({

{isOrganizer ? ( @@ -213,7 +353,7 @@ export function Wheel({
)} - {eligibleParticipants.length === 0 && ( + {items.length === 0 && (

Add participants to enable wheel spinning

@@ -223,3 +363,6 @@ export function Wheel({
) } + +// Add default export for test compatibility +export default Wheel From b2562e422d7c4951d2585f656731de368180fbaa Mon Sep 17 00:00:00 2001 From: Alexander Belenkov Date: Thu, 7 Aug 2025 12:16:04 +0300 Subject: [PATCH 6/6] feat(room): update role detection logic and improve session duration display in TimerPanel --- src/app/room/components/RoomLayout.tsx | 6 +++--- src/app/room/components/TimerPanel.tsx | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/app/room/components/RoomLayout.tsx b/src/app/room/components/RoomLayout.tsx index 0859e7a..51f2563 100644 --- a/src/app/room/components/RoomLayout.tsx +++ b/src/app/room/components/RoomLayout.tsx @@ -51,11 +51,11 @@ export function RoomLayout({ roomId, initialData }: RoomLayoutProps) { // In production, this would be handled by proper authentication const isOrganizer = RoleDetectionService.isOrganizer(roomId) - if (!isOrganizer) { - // Mark as organizer if no cookie exists (first visitor) - RoleDetectionService.setAsOrganizer(roomId) + if (isOrganizer) { setUserRole(ParticipantRoleEnum.ORGANIZER) } else { + // Mark as organizer if no cookie exists (first visitor) + RoleDetectionService.setAsOrganizer(roomId) setUserRole(ParticipantRoleEnum.ORGANIZER) } diff --git a/src/app/room/components/TimerPanel.tsx b/src/app/room/components/TimerPanel.tsx index 5af2efe..74d8728 100644 --- a/src/app/room/components/TimerPanel.tsx +++ b/src/app/room/components/TimerPanel.tsx @@ -335,7 +335,9 @@ export function TimerPanel({

Session Info

    -
  • • Timer duration: {timerDurationMinutes} minutes
  • +
  • + • Session duration: {timerStartTime ? timerDurationMinutes : selectedTime} minutes +
  • • Yellow warning: Last 2 minutes
  • • Red critical: Last 30 seconds
  • {isOrganizer &&
  • • Click "Mark as Finished" to end early
  • }