Add user state updates and bidding error handling in draft consumers

- Implement user state tracking and broadcasting on connect/disconnect and phase changes
- Add bid start and place rejection handling with error messages to frontend and backend
- Enhance movie serializer with TMDB integration and update relevant frontend components
This commit is contained in:
2025-08-24 17:16:22 -05:00
parent baddca8d50
commit 5e08fdc9a2
15 changed files with 385 additions and 242 deletions

View File

@@ -2,6 +2,7 @@ from rest_framework import serializers
from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie, Season
from draft.models import DraftSession, DraftSessionSettings, DraftPick
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
User = get_user_model()
@@ -16,10 +17,27 @@ class UserSerializer(serializers.ModelSerializer):
return f"{obj.first_name} {obj.last_name}".strip()
class MovieSerializer(serializers.ModelSerializer):
tmdb_data = serializers.SerializerMethodField()
def get_tmdb_data(self, obj):
if hasattr(obj, 'imdb_id') and obj.imdb_id:
tmdb_movie = get_tmdb_movie_by_imdb(obj.imdb_id)
if tmdb_movie:
poster_url = None
if tmdb_movie.get('poster_path'):
poster_url = f"{tmdb_movie['poster_path']}"
return {
'id': tmdb_movie.get('id'),
'title': tmdb_movie.get('title'),
'overview': tmdb_movie.get('overview'),
'poster_url': tmdb_movie['poster_url'],
'release_date': tmdb_movie.get('release_date'),
}
return None
class Meta:
model = Movie
# fields = ("id", "imdb_id", "title", "year", "poster_url")
fields = ("id", "title")
fields = ("id", "title", "tmdb_data")
class DraftSessionSettingsSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -12,13 +12,15 @@ tmdb.language = "en"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w500"
def get_tmdb_movie_by_imdb(imdb_id):
def get_tmdb_movie_by_imdb(imdb_id, cache_poster=True):
"""
Fetch TMDb metadata by IMDb ID, using cache to avoid redundant API calls.
"""
cache_key = f"tmdb:movie:{imdb_id}"
cached = cache.get(cache_key)
if cached:
if cache_poster and not cached.get('poster_url'):
cached['poster_url'] = cache_tmdb_poster(cached['poster_path'])
return cached
results = Movie().external(external_id=imdb_id, external_source="imdb_id")
@@ -27,6 +29,8 @@ def get_tmdb_movie_by_imdb(imdb_id):
movie_data = results.movie_results[0]
cache.set(cache_key, movie_data, timeout=60 * 60 * 24) # 1 day
if cache_poster:
movie_data['poster_url'] = cache_tmdb_poster(movie_data['poster_path'])
return movie_data

View File

@@ -11,6 +11,7 @@ class DraftMessage(StrEnum):
USER_JOIN_INFORM = "user.join.inform" # server -> client
USER_LEAVE_INFORM = "user.leave.inform"
USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client
USER_STATE_INFORM = "user.state.inform"
# Phase control
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
@@ -31,8 +32,10 @@ class DraftMessage(StrEnum):
# Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
BID_START_REJECT = "bid.start.reject" # server -> client (movie, ends_at)
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
BID_PLACE_CONFIRM = "bid.update.confirm" # server -> client (high bid)
BID_PLACE_REJECT = "bid.place.reject" # server -> client (high bid)
BID_PLACE_CONFIRM = "bid.place.confirm" # server -> client (high bid)
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
BID_END_INFORM = "bid.end.inform" # server -> client (winner)

View File

@@ -4,23 +4,19 @@ from django.core.exceptions import PermissionDenied
from boxofficefantasy.models import League, Season
from boxofficefantasy.views import parse_season_slug
from draft.models import DraftSession, DraftSessionParticipant
import asyncio
from django.contrib.auth.models import User
from draft.constants import (
DraftMessage,
DraftPhase,
DraftGroupChannelNames,
)
from draft.state import DraftStateManager
from draft.state import DraftStateManager, DraftStateException
from typing import Any
import logging
logger = logging.getLogger(__name__) # __name__ = module path
import random
class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames
draft_state: DraftStateManager
@@ -61,6 +57,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
return
else:
await self.accept()
self.draft_state.connect_participant(self.user.username)
await self.channel_layer.group_add(
self.group_names.session, self.channel_name
)
@@ -72,14 +69,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"payload": {"user": self.user.username},
},
)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "direct.message",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
await self.channel_layer.send(
self.channel_name,
{
@@ -88,6 +77,7 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"payload": {"user": self.user.username},
},
)
await self.broadcast_state()
async def should_accept_user(self) -> bool:
return self.user.is_authenticated
@@ -106,6 +96,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
# --- Convenience helpers ---
async def send_draft_state(self):
"""Send the current draft state only to this client."""
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.USER_STATE_INFORM,
"payload": self.draft_state.user_state(self.user),
}
)
await self.channel_layer.send(
self.channel_name,
{
@@ -117,6 +115,14 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
async def broadcast_state(self):
"""Broadcast current draft state to all in session group."""
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.USER_STATE_INFORM,
"payload": [self.draft_state.user_state(user) for user in self.draft_participants],
}
)
await self.channel_layer.group_send(
self.group_names.session,
{
@@ -214,6 +220,18 @@ class DraftAdminConsumer(DraftConsumerBase):
},
)
await self.broadcast_state()
case DraftPhase.BIDDING:
await self.set_draft_phase(DraftPhase.BIDDING)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.PHASE_CHANGE_CONFIRM,
"payload": {"phase": self.draft_state.phase},
},
)
await self.broadcast_state()
case DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST:
self.draft_state.draft_index_advance()
@@ -245,16 +263,26 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.broadcast_state()
case DraftMessage.BID_START_REQUEST:
self.draft_state.start_bidding()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {**self.draft_state},
},
)
await self.broadcast_state()
try:
self.draft_state.start_bidding()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {**self.draft_state},
},
)
await self.broadcast_state()
except DraftStateException as e:
await self.channel_layer.send(
self.channel_name, {
"type": "direct.message",
"subtype": DraftMessage.BID_START_REJECT,
"payload": {'message': str(e)}
}
)
# === Draft logic ===
@@ -314,12 +342,14 @@ class DraftParticipantConsumer(DraftConsumerBase):
},
},
)
await self.broadcast_state()
await super().disconnect(close_code)
self.draft_state.disconnect_participant(self.user.username)
await self.channel_layer.group_discard(
self.group_names.session, self.channel_name
)
def should_accept_user(self):
return super().should_accept_user() and self.user in self.draft_participants
@@ -333,7 +363,7 @@ class DraftParticipantConsumer(DraftConsumerBase):
"type": "broadcast.admin",
"subtype": event_type,
"payload": {
"movie_id": content.get("payload", {}).get("id"),
"movie_id": content.get("payload", {}).get("movie_id"),
"user": content.get("payload", {}).get("user"),
},
},
@@ -341,15 +371,26 @@ class DraftParticipantConsumer(DraftConsumerBase):
if event_type == DraftMessage.BID_PLACE_REQUEST:
bid_amount = content.get("payload", {}).get("bid_amount")
self.draft_state.place_bid(self.user, bid_amount)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_PLACE_CONFIRM,
"payload": {**self.draft_state},
},
)
try:
self.draft_state.place_bid(self.user, bid_amount)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_PLACE_CONFIRM,
"payload": {'user': self.user.username, 'bid': bid_amount},
},
)
except DraftStateException as e:
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_PLACE_REJECT,
"payload": {'user': self.user.username, 'bid': bid_amount, 'error':str(e)},
},
)
await self.broadcast_state()
# === Broadcast handlers ===
@@ -358,11 +399,8 @@ class DraftParticipantConsumer(DraftConsumerBase):
# === Draft ===
async def nominate(self, movie_title): ...
async def place_bid(self, amount, user): ...
# === Example DB Access ===
# === DB Access ===
@database_sync_to_async
def add_draft_participant(self):

View File

@@ -22,9 +22,11 @@ class DraftCache:
bids: str
bid_timer_start: str
bid_timer_end: str
connected_participants: str
_cached_properties = {
"participants",
"connected_participants",
"phase",
"draft_order",
"draft_index",
@@ -32,7 +34,6 @@ class DraftCache:
"bids",
"bid_timer_start",
"bid_timer_end",
}
def __init__(self, draft_id: str, cache: BaseCache = cache):
@@ -71,8 +72,7 @@ class DraftStateManager:
self.session_id: str = session.hashid
self.cache: DraftCache = DraftCache(self.session_id, cache)
self.settings: DraftSessionSettings = session.settings
self.participants: set[User] = set(session.participants.all())
self.connected_participants: set[User] = set()
self._participants = list(session.participants.all())
# === Phase Management ===
@property
@@ -85,12 +85,21 @@ class DraftStateManager:
# === Connected Users ===
@property
def connected_participants(self):
return set(json.loads(self.cache.connected_participants or "[]"))
def connect_participant(self, username: str):
self.connected_participants.add(username)
return self.connected_participants
connected_participants = self.connected_participants
connected_participants.add(username)
self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
def disconnect_participant(self, username: str):
self.connected_participants.discard(username)
connected_participants = self.connected_participants
connected_participants.discard(username)
self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
# === Draft Order ===
@property
@@ -107,7 +116,7 @@ class DraftStateManager:
self.phase = DraftPhase.DETERMINE_ORDER
self.draft_index = 0
draft_order = random.sample(
list(self.participants), len(self.participants)
list(self._participants), len(self._participants)
)
self.draft_order = [user.username for user in draft_order]
return self.draft_order
@@ -169,18 +178,29 @@ class DraftStateManager:
if isinstance(amount, str):
amount = int(amount)
bids = self.get_bids()
bids.append({"user":user.username, "amount":amount})
user_state = self.user_state(user)
timestamp = int(time.time() * 1000)
if not user_state['can_bid']:
raise DraftStateException('Cannot bid')
if not user_state['remaining_budget'] > amount:
raise DraftStateException('No Budget Remaining')
if not self.get_timer_end() or not timestamp < self.get_timer_end() * 1000:
raise DraftStateException("Timer Error")
bids.append({"user":user.username, "amount":amount, 'timestamp': timestamp})
self.cache.bids = json.dumps(bids)
def get_bids(self) -> dict:
return json.loads(self.cache.bids or "[]")
def current_movie(self) -> Movie | None:
movie_id = self.current_movie
return Movie.objects.filter(pk=movie_id).first() if movie_id else None
movie_id = self.cache.current_movie
return movie_id if movie_id else None
def start_bidding(self):
if not self.phase == DraftPhase.BIDDING:
raise DraftStateException('Not the right phase for that')
if not self.current_movie():
raise DraftStateException('No movie nominated')
seconds = self.settings.bidding_duration
start_time = time.time()
end_time = start_time + seconds
@@ -202,6 +222,7 @@ class DraftStateManager:
"draft_index": self.draft_index,
"connected_participants": list(self.connected_participants),
"current_movie": self.cache.current_movie,
"awards": [],
"bids": self.get_bids(),
"bidding_timer_end": self.get_timer_end(),
"bidding_timer_start": self.get_timer_start(),
@@ -209,6 +230,17 @@ class DraftStateManager:
"next_picks": picks[1:] if picks else []
}
def user_state(self, user: User) -> dict:
picks = self.next_picks(include_current=True)
return {
"is_admin": user.is_staff,
"user": user.username,
"can_bid": self.phase == DraftPhase.BIDDING,
"can_nominate": self.phase == DraftPhase.NOMINATING and picks[0].get('participant') == user.username,
"movies":[],
"remaining_budget":100,
}
# def __dict__(self):
# return self.get_summary()

View File

@@ -3,10 +3,11 @@
{% load static %}
<script>
window.draftSessionId = "{{ draft_id_hashed }}"
window.isAdmin = "{{user.is_staff}}"
console.log("{{user}}")
</script>
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% if user.is_staff %}
<div id="draft-admin-bar-root" data-draft-id="{{ draft_id_hashed }}">You are admin!</div>
{% endif %}
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% endblock body %}

View File

@@ -38,7 +38,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setDraftDetails(data)
})
}, [])

View File

@@ -3,73 +3,24 @@ import React, { useEffect, useState, useRef } from "react";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { WebSocketStatus } from "./components/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhases } from './constants.js';
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "./utils.js";
import { DraftMessage, DraftPhaseLabel, DraftPhase } from './constants.js';
import { fetchDraftDetails, isEmptyObject } from "./utils.js";
import { DraftMoviePool } from "./components/DraftMoviePool.jsx";
import { ParticipantList } from "./components/ParticipantList.jsx";
import { DraftCountdownClock } from "./components/DraftCountdownClock.jsx"
import { handleDraftStatusMessages } from './utils.js'
import { handleDraftStatusMessages, handleUserStatusMessages, handleUserIdentifyMessages } from './utils.js'
// import { Collapse } from 'bootstrap/dist/js/bootstrap.bundle.min.js';
import { Collapse, ListGroup } from "react-bootstrap";
const NominateMenu = ({ socket, draftState, draftDetails, currentUser, }) => {
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
const [open, setOpen] = useState(false);
const { movies } = draftDetails
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
id: formData.get('movie'),
user: currentUser
}
}))
}
useEffect(() => {
if (isEmptyObject(draftState) || isEmptyObject(draftState.current_pick)) return;
if (currentUser == draftState.current_pick.participant) {
setOpen(true)
} else {
setOpen(false)
}
// collapse.toggle()
}, [draftState])
return (
<Collapse in={open} className="nominate-menu">
<div> {/* Everything must be wrapped in one parent */}
<label>Nominate</label>
<div className="d-flex">
<form onSubmit={requestNomination}>
<select className="form-control" name="movie">
{movies.map(m => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
<button className="btn btn-primary">Nominate</button>
</form>
</div>
</div>
</Collapse>
);
}
import { Collapse } from "react-bootstrap";
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [userStatus, setUserState] = useState([]);
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
console.log(socket)
useEffect(() => {
fetchDraftDetails(draftSessionId)
@@ -85,12 +36,15 @@ export const DraftParticipant = ({ draftSessionId }) => {
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const userStatusMessageHandler = (event) => handleUserStatusMessages(event, setUserState)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
socket.addEventListener('message', userStatusMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
socket.removeEventListener('message', userStatusMessageHandler);
};
}, [socket]);
@@ -98,7 +52,6 @@ export const DraftParticipant = ({ draftSessionId }) => {
event.preventDefault()
const form = event.target
const formData = new FormData(form)
console.log('submitting bid...')
socket.send(JSON.stringify({
type: DraftMessage.BID_PLACE_REQUEST,
payload: {
@@ -108,8 +61,12 @@ export const DraftParticipant = ({ draftSessionId }) => {
}))
}
const currentUserStatus = userStatus.find(u => u.user == currentUser)
const currentMovie = movies.find(i => draftState.current_movie == i.id)
return (
<div className="wrapper">
<div className={`wrapper`}>
<section id="draft-live">
<div className="panel">
<header className="panel-header">
@@ -120,64 +77,55 @@ export const DraftParticipant = ({ draftSessionId }) => {
</div>
</header>
<div className="panel-body">
<div id="draft-clock">
<DraftCountdownClock draftState={draftState}></DraftCountdownClock>
<div className="pick-description">
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall + 1} Overall</div>
</div>
</div>
<div className="bid-controls btn-group d-flex flex-column">
<a className="btn btn-primary d-none" data-bs-toggle="collapse" aria-expanded="true" aria-controls="collapse-1" href="#collapse-1" role="button">Show Content</a>
<div id="collapse-1" className="collapse show">
<div>
<div className="row g-0 border rounded-2 m-2">
<div className="col-3"><img className="img-fluid flex-fill" /></div>
<div className="col d-flex justify-content-center align-items-center">
<span className="fw-bold">Movie title</span>
</div>
</div>
</div>
<div>
<div className="lh-sm text-center border p-0 border-bottom"><span>Bids</span></div>
<div className="bids-container">
<ol className="list-group list-group-flush">
{draftState.bids?.reverse().map((b,idx) => (
<li key={idx} className="list-group-item p-0">
<div className="row g-0">
<div className="col-8 col-xl-9 col-xxl-10"><span>{b.user}</span></div>
<div className="col"><span>{b.amount}</span></div>
</div>
</li>
))}
</ol>
</div>
</div>
<div className="p-1">
<form id="bid" onSubmit={submitBidRequest}>
<div className="input-group input-group-sm">
<span className="input-group-text">Bid</span>
<input className="form-control" type="number" id="bidAmount" name="bidAmount"/>
<button className="btn btn-primary" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</div>
<div>
<ul className="pick-list">
<li>
<div>Current Pick: {draftState.current_pick?.participant}</div>
</li>
<li
>
<div>Next Pick: {draftState.next_picks ? draftState.next_picks[0]?.participant : ""}</div>
</li>
</ul>
<div id="draft-clock">
<DraftCountdownClock draftState={draftState}></DraftCountdownClock>
<div className="pick-description">
<div>Round {draftState.current_pick?.round}</div>
<div>Pick {draftState.current_pick?.pick_in_round}</div>
<div>{draftState.current_pick?.overall + 1} Overall</div>
</div>
</div>
<div className="bid-controls btn-group d-flex flex-column">
<Collapse in={draftState.phase == DraftPhase.BIDDING}>
<div>
<div>
<div className="row g-0 border rounded-2 m-2">
<div className="col-3">
<img className="img-fluid flex-fill" src={currentMovie?.tmdb_data?.poster_url}/>
</div>
<div className="col d-flex justify-content-center align-items-center">
<span className="fw-bold">{currentMovie?.title}</span>
</div>
</div>
</div>
<div>
<div className="lh-sm text-center border p-0 border-bottom"><span>Bids</span></div>
<div className="bids-container">
<ol className="list-group list-group-flush">
{draftState.bids?.reverse().map((b, idx) => (
<li key={idx} className="list-group-item p-0">
<div className="row g-0">
<div className="col-8 col-xl-9 col-xxl-10"><span>{b.user}</span></div>
<div className="col"><span>{b.amount}</span></div>
</div>
</li>
))}
</ol>
</div>
</div>
<div className="p-1">
<form id="bid" onSubmit={submitBidRequest}>
<div className="input-group input-group-sm">
<span className="input-group-text">Bid</span>
<input className="form-control" type="number" id="bidAmount" name="bidAmount" />
<button className="btn btn-primary" type="submit">Submit</button>
</div>
</form>
</div>
</div>
</Collapse>
</div>
</div>
</div>
</div>
@@ -185,16 +133,11 @@ export const DraftParticipant = ({ draftSessionId }) => {
<section className="panel" id="draft-slate">
<header className="panel-header">
<div className="panel-title"><span>Draft Catalog</span></div>
<div className="panel-title"><span>Films</span></div>
</header>
<div className="panel-body">
<div className="current-movie card">
<span>Current Nomination: {movies.find(i => draftState.current_movie == i.id)?.title}</span>
</div>
{/* <NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu> */}
<div className="movie-filters"></div>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<DraftMoviePool currentUserStatus={currentUserStatus} currentUser={currentUser} socket={socket} isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
@@ -217,16 +160,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
<div className="panel-title"><span>Teams</span></div>
</header>
<div className="panel-body">
<ul className="team-list list-group">
{draftState.participants?.map(p => (
<li className="team-item list-group-item" key={p}>
<div className="team-name fw-bold">{p}</div>
<ul className="team-movie-list list-group list-group-flush">
<li className="team-movie-item list-group-item"></li>
</ul>
</li>
))}
</ul>
<ParticipantList currentUser={currentUser} className="team-list" draftDetails={draftDetails} draftState={draftState} isAdmin={isAdmin}></ParticipantList>
</div>
</section>

View File

@@ -1,24 +1,26 @@
import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ draftState }) {
// endTime is in seconds (Unix time)
const {bidding_timer_end, onFinish} = draftState
const { bidding_timer_end, onFinish } = draftState;
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(bidding_timer_end));
useEffect(() => {
if (timeLeft <= 0) {
setTimeLeft(getTimeLeft(bidding_timer_end)); // reset timer when bidding_timer_end changes
if (getTimeLeft(bidding_timer_end) <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(bidding_timer_end);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line
}, [bidding_timer_end, onFinish, timeLeft]);
}, [bidding_timer_end, onFinish]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;

View File

@@ -1,23 +1,87 @@
import React from "react";
import { isEmptyObject } from "../utils";
import { DraftMessage } from "../constants";
export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
if(isEmptyObject(draftDetails)) {return}
const {movies} = draftDetails
const {current_movie} = draftState
const NominateForm = ({ socket, currentUser, movie, className}) => {
const requestNomination = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: formData.get('movie_id'),
user: currentUser
}
}))
}
return (
<form onSubmit={requestNomination} className={className}>
<input type="hidden" name="movie_id" value={movie.id} />
<button type="submit" className="btn btn-primary nominate">
Nominate
</button>
</form>
);
}
export const DraftMoviePool = ({ socket, currentUser, currentUserStatus, draftDetails, draftState, isNominating = false }) => {
if (isEmptyObject(draftDetails)) { return }
const { movies } = draftDetails
const { current_movie } = draftState
const can_nominate = currentUserStatus?.can_nominate
const is_admin = currentUserStatus?.is_admin
const nominateHandler = (event) => {
event.preventDefault()
const formData = new FormData(event.target)
const movieId = formData.get('movie_id');
socket.send(JSON.stringify({
type: DraftMessage.NOMINATION_SUBMIT_REQUEST,
payload: {
movie_id: movieId,
user: currentUser
}
}))
}
return (
<div className="movie-pool-container">
<label>Movies</label>
<ul>
{movies.map(m => (
<li key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null }`}>
<a href={`/api/movie/${m.id}/detail`}>
{m.title}
</a>
</li>
))}
</ul>
<table className="table">
<thead>
<tr>
<th>Poster</th>
<th>Title</th>
<th>Release Date</th>
</tr>
</thead>
<tbody>
{movies.map(m => (
<tr key={m.id} className={`${current_movie == m.id ? "current-movie fw-bold" : null}`}>
<td><img src={m.tmdb_data.poster_url}></img></td>
<td>
<div>
<a href={`/api/movie/${m.id}/detail`} className="fs-5">
{m.title}
</a>
</div>
<div>
<a href={`https://www.themoviedb.org/movie/${m.tmdb_data.id}`}>
TMDB
</a>
</div>
<div>
{can_nominate || is_admin ? (
<NominateForm socket={socket} currentUser={currentUser} movie={m} className={!can_nominate && is_admin ? 'admin-override' : ''}></NominateForm>
) : null}
</div>
</td>
<td>{m.tmdb_data.release_date}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -1,32 +1,38 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../utils.js"
import Badge from 'react-bootstrap/Badge';
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { console.warn('empty draft state', draftState); return }
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { return }
const { draft_order, draft_index, connected_participants } = draftState
const { participants } = draftDetails
const ListTag = draft_order?.length > 0 ? "ol" : "ul"
const listItems = draft_order?.length > 0 ? draft_order.map(d => participants.find(p => p.username == d)) : participants
return (
<div className="participant-list-container">
<label>Particpants</label>
<ListTag className="participant-list">
{listItems.map((p, i) => (
<li key={i} className={`${i == draft_index ? "fw-bold" : ""}`}>
<span className={`${p.username == currentUser ? "current-user" : ""}`}>{p?.full_name}</span>
{isAdmin ? (
<div
<ListTag className="participant-list">
{listItems.map((p, idx) => (
<li className="team-item" key={idx}>
<div className={`team-name ${p.username == currentUser ? "current-user" : ""}`}>
<div>
{p.full_name}
{p.username == draftState.current_pick?.participant ? (<Badge bg="warning" className="ms-1">Current Pick</Badge>) : null}
</div>
<ul className="team-movie-list list-group list-group-flush">
<li className="team-movie-item list-group-item"></li>
</ul>
</div>
{isAdmin === "True" ? (
<div
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
</div>
) : null}
</li>
))}
</ListTag>
)
}

View File

@@ -10,6 +10,7 @@ export const DraftMessage = {
USER_JOIN_INFORM: "user.join.inform",
USER_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.inform",
USER_STATE_INFORM: "user.state.inform",
PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
@@ -21,8 +22,10 @@ export const DraftMessage = {
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request",
BID_START_REJECT: "bid.start.reject",
BID_PLACE_REQUEST: "bid.place.request",
BID_PLACE_CONFIRM: "bid.update.confirm",
BID_PLACE_REJECT: "bid.place.reject",
BID_PLACE_CONFIRM: "bid.place.confirm",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",

View File

@@ -50,8 +50,16 @@ export const handleUserIdentifyMessages = (event, setUser) => {
const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data);
const { user } = payload;
setUser(user);
}
};
export const handleUserStatusMessages = (event, setUserStatus) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_STATE_INFORM) {
setUserStatus(payload);
}
};

View File

@@ -11,13 +11,13 @@ import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminBarRoot = document.getElementById("draft-admin-bar-root");
const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId} = window; // from backend template
const {draftSessionId, isAdmin} = window; // from backend template
if (draftPartipantRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
createRoot(draftPartipantRoot).render(
<WebSocketProvider url={wsUrl}>
<DraftParticipant draftSessionId={draftSessionId} />
<DraftParticipant draftSessionId={draftSessionId} className={`${isAdmin ? 'admin':''}`}/>
</WebSocketProvider>
);
}

View File

@@ -1,6 +1,6 @@
@use "../../node_modules/bootstrap/scss/bootstrap.scss";
@use "./fonts/graphique.css";
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=League+Gothic&family=Oswald:wght@200..700&display=swap');
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&family=League+Gothic&family=Oswald:wght@200..700&display=swap");
// Import only functions & variables
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@@ -95,38 +95,62 @@
}
}
.participant-list-container,
.movie-pool-container {
max-width: 575.98px;
label {
@extend .fs-3;
}
ol.participant-list {
@extend .list-group-numbered;
}
ol.participant-list,
ul.participant-list {
@extend .list-group;
ol,
ul {
@extend .p-0;
}
ol {
@extend .list-group-numbered;
}
li {
@extend .list-group-item;
@extend .d-flex;
@extend .justify-content-between;
@extend .align-items-center;
span {
@extend .me-auto;
@extend .ps-1;
.team-name {
@extend .flex-grow-1;
@extend .ps-2;
}
.team-movie-list {
li {
@extend .p-0;
}
}
}
.current-user {
@extend .fw-bold;
&::after {
content: " *";
// content: " *";
font-size: 1em; // adjust as needed
}
}
}
.movie-pool-container {
img {
height: 128px;
}
a {
@extend .text-decoration-none;
@extend .text-reset;
}
thead {
display: block;
}
tbody {
display: block;
// height: 200px; /* or any desired height */
overflow-y: auto;
}
th,
td {
width: 150px; /* Set consistent widths to align columns */
box-sizing: border-box;
}
}
#draft-admin-bar {
@extend .d-flex;
@extend .flex-column;
@@ -137,7 +161,13 @@
@extend .shadow-sm;
div {
@extend .d-flex;
@extend .justify-content-center
@extend .justify-content-center;
}
}
.admin-override {
button {
@extend .btn-warning;
}
}
@@ -195,7 +225,7 @@
@extend .text-bg-dark;
@extend .lh-1;
.countdown-clock {
font-family: 'League Gothic';
font-family: "League Gothic";
font-size: $font-size-base * 5;
@extend .fw-bolder;
@extend .col;
@@ -207,7 +237,8 @@
@extend .align-content-center;
}
}
div:has(.pick-list), div:has(.bid-list){
div:has(.pick-list),
div:has(.bid-list) {
ul {
@extend .list-group;
}