Files
turn-tracker/frontend/src/components/pages/LobbyPage.tsx
AtHeartEngineer da0e03e287 init
2025-07-21 23:02:42 -04:00

813 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import {
Container,
Typography,
Box,
Card,
CardContent,
TextField,
Button,
Alert,
CircularProgress,
Chip,
List,
ListItem,
ListItemText,
ListItemAvatar,
Avatar,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Snackbar,
Divider,
Tooltip,
Stack,
IconButton,
} from '@mui/material'
import {
ContentCopy,
DragIndicator,
Delete,
Share,
PlayArrow,
Stop,
Lock,
LockOpen,
SkipNext,
KeyboardDoubleArrowRight,
} from '@mui/icons-material'
import {
DndContext,
closestCenter,
DragEndEvent,
useSensor,
useSensors,
PointerSensor,
KeyboardSensor
} from '@dnd-kit/core'
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
sortableKeyboardCoordinates
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import axios from 'axios'
import { useAuth } from '../../contexts/AuthContext'
import AppNavigation from '../AppNavigation'
interface PlayerData {
username: string
left?: boolean
}
interface Lobby {
id: string
name: string
description?: string
owner: string
players: PlayerData[]
turn: number
inviteCode: string
password?: string
locked?: boolean
}
interface SortablePlayerProps {
player: PlayerData
index: number
isCurrentTurn: boolean
isOwner: boolean
canManage: boolean
locked: boolean
onRemove: (username: string) => void
onSetCurrentTurn: (username: string) => void
}
const SortablePlayer: React.FC<SortablePlayerProps> = ({
player,
index,
isCurrentTurn,
isOwner,
canManage,
locked,
onRemove,
onSetCurrentTurn
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: player.username, disabled: locked })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<ListItem
ref={setNodeRef}
style={style}
sx={{
mb: 1,
bgcolor: isCurrentTurn ? 'primary.light' : 'background.paper',
borderRadius: 2,
boxShadow: isCurrentTurn ? 2 : 1,
border: isCurrentTurn ? '2px solid' : '1px solid',
borderColor: isCurrentTurn ? 'primary.main' : 'divider',
opacity: player.left ? 0.5 : 1,
}}
secondaryAction={
canManage && !locked && !player.left && (
<Box display="flex" gap={1}>
{!isCurrentTurn && (
<Tooltip title="Set as current turn">
<IconButton
onClick={() => onSetCurrentTurn(player.username)}
color="primary"
size="small"
>
<KeyboardDoubleArrowRight />
</IconButton>
</Tooltip>
)}
<Tooltip title="Remove player">
<IconButton
onClick={() => onRemove(player.username)}
color="error"
size="small"
>
<Delete />
</IconButton>
</Tooltip>
</Box>
)
}
>
{canManage && (
<ListItemAvatar
{...(!locked ? attributes : {})}
{...(!locked ? listeners : {})}
sx={{ cursor: locked ? 'default' : 'grab' }}
>
<Avatar sx={{
bgcolor: isCurrentTurn ? 'primary.main' : 'grey.400',
opacity: locked ? 0.5 : 1
}}>
<DragIndicator />
</Avatar>
</ListItemAvatar>
)}
<ListItemText
primary={
<Box display="flex" alignItems="center" gap={1}>
<Typography
variant="h6"
sx={{
textDecoration: player.left ? 'line-through' : 'none',
color: isCurrentTurn ? 'primary.contrastText' : 'text.primary',
}}
>
{player.username}
</Typography>
{isOwner && <Chip label="Owner" size="small" color="secondary" />}
{isCurrentTurn && <Chip label="Current Turn" size="small" color="primary" />}
{player.left && <Chip label="Left" size="small" variant="outlined" />}
</Box>
}
secondary={isCurrentTurn ? "It's your turn!" : `Position ${index + 1}`}
sx={{
'& .MuiListItemText-secondary': {
color: isCurrentTurn ? 'primary.contrastText' : 'text.secondary',
}
}}
/>
</ListItem>
)
}
const LobbyPage: React.FC = () => {
const { user, notificationsEnabled } = useAuth()
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
// State management
const [lobby, setLobby] = useState<Lobby | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [snackbar, setSnackbar] = useState<{ open: boolean, message: string, severity: 'success' | 'error' }>({
open: false,
message: '',
severity: 'success'
})
// Settings
const [settingsOpen, setSettingsOpen] = useState(false)
const [newGameName, setNewGameName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newPassword, setNewPassword] = useState('')
// Leave confirmation
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false)
// Invite confirmation
const [inviteConfirmOpen, setInviteConfirmOpen] = useState(false)
const [inviteCode, setInviteCode] = useState<string | null>(null)
// Drag and drop
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const fetchLobby = useCallback(async () => {
try {
const response = await axios.get(`http://localhost:3001/api/lobbies/${id}`)
setLobby(response.data)
setNewGameName(response.data.name)
setNewDescription(response.data.description || '')
// Check for pending invite after loading lobby
const pendingInvite = localStorage.getItem('pendingInvite')
if (pendingInvite && response.data.inviteCode === pendingInvite) {
// Check if user is already in the lobby
const userInLobby = response.data.players.some((p: any) => p.username === user?.username && !p.left)
if (!userInLobby) {
setInviteCode(pendingInvite)
setInviteConfirmOpen(true)
}
localStorage.removeItem('pendingInvite')
}
} catch (error) {
setError('Failed to load lobby')
} finally {
setLoading(false)
}
}, [id, user?.username])
useEffect(() => {
if (!user) {
navigate('/')
return
}
fetchLobby()
// WebSocket connection
const ws = new WebSocket('ws://localhost:3001')
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
if (message.lobby?.id === id) {
setLobby(message.lobby)
setNewGameName(message.lobby.name)
// Show notification for turn changes
if (message.type === 'turn-change' && message.lobby.players[message.lobby.turn]?.username === user?.username) {
showSnackbar("It's your turn!", 'success')
if (notificationsEnabled && Notification.permission === 'granted') {
new Notification("Your Turn!", {
body: `It's your turn in ${message.lobby.name}`,
icon: '/logo192.png',
})
}
}
}
}
// Polling backup every 5 minutes
const interval = setInterval(fetchLobby, 5 * 60 * 1000)
return () => {
ws.close()
clearInterval(interval)
}
}, [id, user, navigate, notificationsEnabled, fetchLobby])
const showSnackbar = (message: string, severity: 'success' | 'error' = 'success') => {
setSnackbar({ open: true, message, severity })
}
const handleEndTurn = async () => {
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/end-turn`, {
username: user?.username
})
showSnackbar('Turn advanced!')
} catch (error) {
showSnackbar('Failed to end turn', 'error')
}
}
const handleLockToggle = async () => {
if (!lobby || !user || lobby.owner !== user.username) return
try {
const endpoint = lobby.locked ? 'unlock' : 'lock'
await axios.post(`http://localhost:3001/api/lobbies/${id}/${endpoint}`, {
username: user.username
})
const action = lobby.locked ? 'unlocked' : 'locked'
showSnackbar(`Turn order ${action}!`, 'success')
fetchLobby()
} catch (error) {
showSnackbar('Failed to change lock state', 'error')
}
}
const handleLeaveLobby = () => {
setLeaveConfirmOpen(true)
}
const confirmLeaveLobby = async () => {
if (!user) return
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/leave`, { username: user.username })
showSnackbar('Left lobby')
navigate('/')
} catch (error) {
showSnackbar('Failed to leave lobby', 'error')
}
setLeaveConfirmOpen(false)
}
const handleJoinFromInvite = async (password?: string) => {
if (!user || !inviteCode) return
try {
await axios.post('http://localhost:3001/api/lobbies/join', {
inviteCode,
username: user.username,
password,
})
setInviteConfirmOpen(false)
setInviteCode(null)
showSnackbar('Successfully joined the lobby!', 'success')
fetchLobby() // Refresh lobby data
} catch (error: any) {
if (error.response?.status === 401) {
showSnackbar('Incorrect password. Please try again.', 'error')
} else if (error.response?.status === 400) {
if (error.response.data.includes('full')) {
showSnackbar('This lobby is full (maximum 10 players).', 'error')
setInviteConfirmOpen(false)
setInviteCode(null)
} else {
showSnackbar('Failed to join lobby. Please try again.', 'error')
}
} else {
showSnackbar('Failed to join lobby. Please try again.', 'error')
}
}
}
const handleDeclineInvite = () => {
setInviteConfirmOpen(false)
setInviteCode(null)
navigate('/')
}
const handleRemovePlayer = async (username: string) => {
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/remove`, { username, removedBy: user?.username })
showSnackbar(`Removed ${username} from lobby`)
} catch (error) {
showSnackbar('Failed to remove player', 'error')
}
}
const handleSetCurrentTurn = async (username: string) => {
if (!user) return
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-current-turn`, {
targetUsername: username,
username: user.username
})
showSnackbar(`Turn set to ${username}`)
} catch (error) {
showSnackbar('Failed to set current turn', 'error')
}
}
const handleSetPassword = async () => {
if (!user) return
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-password`, {
password: newPassword,
username: user.username
})
showSnackbar('Lobby password updated!')
setNewPassword('')
} catch (error) {
showSnackbar('Failed to set password', 'error')
}
}
const handleSaveSettings = async () => {
if (!user) return
try {
// Update name if changed
if (newGameName !== lobby?.name) {
await axios.post(`http://localhost:3001/api/lobbies/${id}/rename`, {
newName: newGameName,
username: user.username
})
}
// Update description if changed
if (newDescription !== (lobby?.description || '')) {
await axios.post(`http://localhost:3001/api/lobbies/${id}/set-description`, {
description: newDescription,
username: user.username
})
}
showSnackbar('Settings saved successfully!')
setSettingsOpen(false)
fetchLobby() // Refresh lobby data
} catch (error) {
showSnackbar('Failed to save settings', 'error')
}
}
const handleDragEnd = async (event: DragEndEvent) => {
if (!lobby || lobby.locked) return
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = lobby.players.findIndex(p => p.username === active.id)
const newIndex = lobby.players.findIndex(p => p.username === over.id)
const newPlayers = arrayMove(lobby.players, oldIndex, newIndex)
try {
await axios.post(`http://localhost:3001/api/lobbies/${id}/reorder`, {
players: newPlayers,
username: user?.username
})
showSnackbar('Player order updated!')
} catch (error) {
showSnackbar('Failed to reorder players', 'error')
}
}
}
const copyInviteLink = () => {
if (!lobby) return
const inviteLink = `${window.location.origin}/join/${lobby.inviteCode}`
navigator.clipboard.writeText(inviteLink)
showSnackbar('Invite link copied to clipboard!')
}
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="100vh">
<CircularProgress size={60} />
</Box>
)
}
if (error || !lobby || !user) {
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Alert severity="error">
{error || 'Lobby not found'}
</Alert>
<Button onClick={() => navigate('/')} sx={{ mt: 2 }}>
Go Home
</Button>
</Container>
)
}
const isOwner = user?.username === lobby.owner
const currentPlayer = lobby.players[lobby.turn]
const isCurrentTurn = currentPlayer?.username === user?.username
return (
<>
<AppNavigation />
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Lobby Header */}
<Box sx={{ mb: 4, textAlign: 'center' }}>
<Typography variant="h2" gutterBottom sx={{ fontWeight: 'bold', color: 'primary.main' }}>
{lobby?.name}
</Typography>
{lobby?.description && (
<Typography variant="h6" color="text.secondary" sx={{ maxWidth: '800px', mx: 'auto' }}>
{lobby.description}
</Typography>
)}
</Box>
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 4,
}}
>
<Box sx={{ flex: { xs: 1, md: '2 1 0%' } }}>
<Card elevation={3}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4" gutterBottom>
Turn Order
</Typography>
<Box>
<Tooltip title="Copy invite link">
<IconButton onClick={copyInviteLink} color="primary">
<Share />
</IconButton>
</Tooltip>
{isOwner && (
<Tooltip title={lobby.locked ? 'Unlock turn order' : 'Lock turn order'}>
<IconButton onClick={handleLockToggle} color={lobby.locked ? 'warning' : 'default'}>
{lobby.locked ? <Lock /> : <LockOpen />}
</IconButton>
</Tooltip>
)}
</Box>
</Box>
{lobby.locked && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Box display="flex" alignItems="center" gap={1}>
<Lock fontSize="small" />
Turn order is locked and cannot be changed
</Box>
</Alert>
)}
{lobby.players.length === 0 ? (
<Alert severity="info">No players in this lobby yet.</Alert>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={lobby.players.map(p => p.username)}
strategy={verticalListSortingStrategy}
>
<List>
{lobby.players.map((player, index) => (
<SortablePlayer
key={player.username}
player={player}
index={index}
isCurrentTurn={lobby.turn === index}
isOwner={player.username === lobby.owner}
canManage={isOwner}
locked={lobby.locked || false}
onRemove={handleRemovePlayer}
onSetCurrentTurn={handleSetCurrentTurn}
/>
))}
</List>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
</Box>
<Box sx={{ flex: { xs: 1, md: '1 1 0%' } }}>
<Stack spacing={3}>
<Card elevation={3}>
<CardContent>
<Typography variant="h5" gutterBottom>
Game Actions
</Typography>
<Divider sx={{ mb: 2 }} />
{isCurrentTurn && (
<Button
fullWidth
variant="contained"
size="large"
startIcon={<PlayArrow />}
onClick={handleEndTurn}
sx={{ mb: 2 }}
>
End My Turn
</Button>
)}
<Button
fullWidth
variant="outlined"
color="error"
startIcon={<Stop />}
onClick={handleLeaveLobby}
>
Leave Lobby
</Button>
</CardContent>
</Card>
<Card elevation={3}>
<CardContent>
<Typography variant="h6" gutterBottom>
Lobby Info
</Typography>
<Divider sx={{ mb: 2 }} />
<Box mb={2}>
<Typography variant="body2" color="text.secondary">
Players: {lobby.players.filter(p => !p.left).length}/{lobby.players.length}
</Typography>
<Typography variant="body2" color="text.secondary">
Current Turn: {currentPlayer?.username || 'Unknown'}
</Typography>
<Typography variant="body2" color="text.secondary">
Owner: {lobby.owner}
</Typography>
</Box>
<Button
fullWidth
variant="outlined"
startIcon={<ContentCopy />}
onClick={copyInviteLink}
>
Copy Invite Link
</Button>
</CardContent>
</Card>
</Stack>
</Box>
</Box>
</Container>
{/* Settings Dialog */}
<Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Lobby Settings</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Lobby Name"
value={newGameName}
onChange={(e) => setNewGameName(e.target.value)}
margin="normal"
/>
<TextField
fullWidth
label="Description (Optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
margin="normal"
multiline
rows={3}
placeholder="What's this game about?"
helperText="Add details about your game or rules"
/>
<TextField
fullWidth
label="New Password (optional)"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
margin="normal"
helperText="Leave empty to remove password"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setSettingsOpen(false)}>Cancel</Button>
<Button onClick={handleSetPassword} variant="outlined">
Update Password
</Button>
<Button onClick={handleSaveSettings} variant="contained">
Save Changes
</Button>
</DialogActions>
</Dialog>
{/* Leave Confirmation Dialog */}
<Dialog open={leaveConfirmOpen} onClose={() => setLeaveConfirmOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle sx={{ color: 'error.main', fontWeight: 'bold' }}>
Are you SUPER SUPER SUPER sure?
</DialogTitle>
<DialogContent>
<Box sx={{ textAlign: 'center', py: 2 }}>
<Typography variant="h6" gutterBottom sx={{ color: 'error.main' }}>
You're about to leave "{lobby?.name}"
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
This action cannot be undone! You will:
</Typography>
<Box sx={{ textAlign: 'left', mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
• ❌ Be marked as "left" in the lobby
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
• 🚫 No longer participate in turns
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
• 📱 Stop receiving notifications
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
• 🔄 Need to be re-invited to rejoin
</Typography>
</Box>
<Typography variant="h6" sx={{ color: 'error.main', fontWeight: 'bold' }}>
Are you absolutely, positively, 100% certain?
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setLeaveConfirmOpen(false)} variant="contained" color="primary" size="large">
No, Keep Me In The Game!
</Button>
<Button onClick={confirmLeaveLobby} variant="outlined" color="error" size="large">
Yes, I'm Super Sure - Leave Forever
</Button>
</DialogActions>
</Dialog>
{/* Invite Confirmation Dialog */}
<Dialog open={inviteConfirmOpen} onClose={handleDeclineInvite} maxWidth="sm" fullWidth>
<DialogTitle sx={{ color: 'primary.main', fontWeight: 'bold' }}>
🎮 Join Game Invitation
</DialogTitle>
<DialogContent>
<Box sx={{ textAlign: 'center', py: 2 }}>
<Typography variant="h6" gutterBottom>
You've been invited to join "{lobby?.name}"
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
Would you like to join this lobby and start playing?
</Typography>
<Box sx={{ textAlign: 'left', mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
• 🎯 Players: {lobby?.players.filter(p => !p.left).length}/10
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
• 👤 Owner: {lobby?.owner}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
• 🔄 Current turn: {lobby?.players[lobby.turn]?.username || 'Loading...'}
</Typography>
{lobby?.password && (
<Typography variant="body2" sx={{ mb: 1 }}>
• 🔒 Password protected
</Typography>
)}
</Box>
{lobby?.password && (
<TextField
fullWidth
label="Lobby Password"
type="password"
id="invite-password"
margin="normal"
placeholder="Enter lobby password"
/>
)}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleDeclineInvite} variant="outlined" color="secondary" size="large">
No Thanks, Maybe Later
</Button>
<Button
onClick={() => {
const passwordField = document.getElementById('invite-password') as HTMLInputElement
const password = passwordField?.value || undefined
handleJoinFromInvite(password)
}}
variant="contained"
color="primary"
size="large"
>
Yes, Join the Game!
</Button>
</DialogActions>
</Dialog>
{/* Snackbar for notifications */}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}
message={snackbar.message}
/>
</>
)
}
export default LobbyPage