Compare commits

...

5 Commits

Author SHA1 Message Date
5e08fdc9a2 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
2025-08-24 17:16:22 -05:00
baddca8d50 Refactor draft app with improved state management and components
* Rename WebSocket message types for better organization
* Improve state handling with dedicated methods like broadcast_state
* Restructure frontend components and remove unused code
2025-08-24 12:06:41 -05:00
b38c779772 # Refactor DraftState for improved type safety and consistency
- Replaced manual cache key handling with `DraftCache` class using properties
- Fixed connected_participants serialization by converting sets to lists
- Updated countdown clock component to accept unified state prop
2025-08-23 13:55:04 -05:00
e8bf313f53 Add live bidding UI and backend support; integrate react-bootstrap
- Added 'react-bootstrap' to frontend dependencies for improved UI components.
- Updated bid placement mechanics: backend now stores bids as a list of {user, amount}; frontend displays live bid leaderboard, including highest bid.
- Implemented bid placement form and UI in participant draft screen.
- Used React-Bootstrap Collapse for nominee menu accordion behavior.
- Expanded DraftStateManager and websocket consumers to broadcast bid updates in the new format.
- Added missing 'bids' syncing to all relevant state handling code.
- Improved styling for bidding, panel headers, and pick lists in SCSS; leveraged Bootstrap variables/utilities more extensively.
- Other minor JS, Python, and style tweaks for better stability and robustness.
2025-08-15 15:38:39 -05:00
9ddc8663a9 feat: improve draft admin UI, draft state sync, and styling
Major refactor of Draft admin and participant Websocket state sync
Use consistent state dict serialization in DraftStateManager (to_dict, dict-like access, etc.)
Always include up-to-date participants and draft status in sync payloads
Draft phase/order summary now sent as objects instead of calling .get_summary()
UI/UX updates:
Updated DraftAdmin.jsx:
Connects DraftParticipant panel for real-time participant state
Centralizes phase advance, bidding, and sync controls
Moves phase selector into a dedicated panel
Refine markup/extends in room_admin.dj.html (use block body, fix root data attribute)
Minor fixes to DraftCountdownClock.jsx to robustly handle NaN time
CSS/layout:
Refactor .draft-participant styling to .wrapper within #draft-participant-root and #draft-admin-root for better responsive layout and code clarity
Server code:
Simplify draft consumer/manager state interaction, drop unused cache keys, update order determination and phase management, and ensure DRY status object responses
Small code style and consistency cleanups
Misc:
Add debugpy launch task in code-workspace and clean workspace JSON (style/consistency)
Minor formatting and error handling improvements
2025-08-15 11:06:27 -05:00
34 changed files with 1311 additions and 630 deletions

View File

@@ -2,6 +2,7 @@ from rest_framework import serializers
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from boxofficefantasy.models import Movie, Season from boxofficefantasy.models import Movie, Season
from draft.models import DraftSession, DraftSessionSettings, DraftPick from draft.models import DraftSession, DraftSessionSettings, DraftPick
from boxofficefantasy.integrations.tmdb import get_tmdb_movie_by_imdb
User = get_user_model() User = get_user_model()
@@ -16,10 +17,27 @@ class UserSerializer(serializers.ModelSerializer):
return f"{obj.first_name} {obj.last_name}".strip() return f"{obj.first_name} {obj.last_name}".strip()
class MovieSerializer(serializers.ModelSerializer): 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: class Meta:
model = Movie model = Movie
# fields = ("id", "imdb_id", "title", "year", "poster_url") # fields = ("id", "imdb_id", "title", "year", "poster_url")
fields = ("id", "title") fields = ("id", "title", "tmdb_data")
class DraftSessionSettingsSerializer(serializers.ModelSerializer): class DraftSessionSettingsSerializer(serializers.ModelSerializer):
class Meta: class Meta:

View File

@@ -7,6 +7,18 @@
"launch": { "launch": {
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{
"name": "Debug current file with debugpy",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false,
"args": [],
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
},
{ {
"name": "Run Django Server", "name": "Run Django Server",
"type": "debugpy", "type": "debugpy",
@@ -22,7 +34,7 @@
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "uvicorn", "module": "uvicorn",
"args": ["boxofficefantasy_project.asgi:application", "--reload",], "args": ["boxofficefantasy_project.asgi:application", "--reload"],
"django": true, "django": true,
"console": "integratedTerminal", "console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env" "envFile": "${workspaceFolder}/.env"
@@ -32,10 +44,7 @@
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"args": [ "args": ["run", "dev"],
"run",
"dev"
],
"cwd": "${workspaceFolder}/frontend", "cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal", "console": "integratedTerminal",
"internalConsoleOptions": "neverOpen" "internalConsoleOptions": "neverOpen"
@@ -62,7 +71,11 @@
"compounds": [ "compounds": [
{ {
"name": "Django + Chrome + Webpack", "name": "Django + Chrome + Webpack",
"configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"], "configurations": [
"Run Django Server",
"Launch Chrome",
"Start Webpack Dev Server"
],
"type": "compound" "type": "compound"
} }
] ]
@@ -152,7 +165,7 @@
"editor.defaultFormatter": "ms-python.black-formatter" "editor.defaultFormatter": "ms-python.black-formatter"
}, },
"[django-html]": { "[django-html]": {
"editor.defaultFormatter": "monosans.djlint", "editor.defaultFormatter": "monosans.djlint"
}, },
"emmet.includeLanguages": { "emmet.includeLanguages": {
"django-html": "html" "django-html": "html"
@@ -164,12 +177,10 @@
"**/__pycache__": true, "**/__pycache__": true,
".venv": false ".venv": false
}, },
"auto-close-tag.activationOnLanguage": [ "auto-close-tag.activationOnLanguage": ["django-html"],
"django-html"
],
"terminal.integrated.env.osx": { "terminal.integrated.env.osx": {
"VSCODE_HISTFILE": "${workspaceFolder}/.venv/.term_history" "VSCODE_HISTFILE": "${workspaceFolder}/.venv/.term_history"
}, }
// "html.autoClosingTags": true, // "html.autoClosingTags": true,
} }
} }

View File

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

View File

@@ -19,7 +19,7 @@
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.js"></script> <script src="https://cdn.datatables.net/2.3.2/js/dataTables.js"></script>
<script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script> <script src="https://cdn.datatables.net/2.3.2/js/dataTables.bootstrap5.js"></script>
</head> </head>
<body> <body class="d-flex flex-column vh-100">
<nav class="navbar justify-content-ends pe-2"> <nav class="navbar justify-content-ends pe-2">
<div> <div>
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
@@ -50,16 +50,20 @@
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}" <li class="breadcrumb-item {% if forloop.last %}active{% endif %}"
aria-current="page"> aria-current="page">
{% if not forloop.last %} {% if not forloop.last %}
<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% else %}{{ crumb.label }}{% endif %} <a href="{{ crumb.url }}">{{ crumb.label }}</a>
{% else %}
{{ crumb.label }}
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ol> </ol>
{% endif %} {% endif %}
</nav> </nav>
{% endblock breadcrumbs %} {% endblock breadcrumbs %}
{% block content %}{% endblock content %} {% block content %}
{% endblock body %} {% endblock content %}
</main> </main>
{% endblock body %}
<footer class="text-muted text-center mt-5"> <footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small> <small>&copy; Sack Lunch</small>
</footer> </footer>

View File

@@ -26,7 +26,7 @@ SECRET_KEY = "django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ["localhost"] ALLOWED_HOSTS = ["localhost", "kif.local"]
# TMDB API KEY # TMDB API KEY
TMDB_API_KEY = os.environ.get("TMDB_API_KEY") TMDB_API_KEY = os.environ.get("TMDB_API_KEY")

75
data/cache_concept.py Normal file
View File

@@ -0,0 +1,75 @@
import pickle
import os
from typing import Any
from pathlib import Path
import json
DEFAULT_PATH = Path("/Users/asc/Developer/boxofficefantasy/main/data/draft_cache.json")
class CachedDraftState:
participants: list
phase: str # Replace with Enum if needed
draft_order: list = []
draft_index: int
current_movie: str
bids: list
def __init__(self, cache_file: str = "draft_cache.json"):
super().__setattr__("_cache_file", cache_file)
super().__setattr__("_cache", self._load_cache())
def _load_cache(self) -> dict:
if os.path.exists(self._cache_file):
try:
with open(self._cache_file, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
print(f"Failed to load cache: {e}")
return {}
return {}
def _save_cache(self):
try:
with open(self._cache_file, "w", encoding="utf-8") as f:
json.dump(self._cache, f, indent=2)
except Exception as e:
print(f"Failed to save cache: {e}")
def __getattr__(self, name: str) -> Any:
if name in self.__class__.__annotations__:
print(f"[GET] {name} -> {self._cache.get(name)}")
return self._cache.get(name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
def __setattr__(self, name: str, value: Any):
if name in self.__class__.__annotations__:
print(f"[SET] {name} = {value}")
self._cache[name] = value
self._save_cache()
else:
super().__setattr__(name, value)
if __name__ == "__main__":
# Clean start for testing
if os.path.exists("draft_cache.pkl"):
os.remove("draft_cache.pkl")
print("\n--- First Run: Setting Attributes ---")
state = CachedDraftState()
state.participants = ["Alice", "Bob"]
state.phase = "nominating"
# state.draft_order = ["Bob", "Alice"]
state.draft_index = 0
state.current_movie = "The Matrix"
state.bids = [{"Alice": 10}, {"Bob": 12}]
print("\n--- Second Run: Reading from Cache ---")
state2 = CachedDraftState()
print("participants:", state2.participants)
print("phase:", state2.phase)
print("draft_order:", state2.draft_order)
print("draft_index:", state2.draft_index)
print("current_movie:", state2.current_movie)
print("bids:", state2.bids)
pass

BIN
data/draft_cache.json Normal file

Binary file not shown.

View File

@@ -11,6 +11,7 @@ class DraftMessage(StrEnum):
USER_JOIN_INFORM = "user.join.inform" # server -> client USER_JOIN_INFORM = "user.join.inform" # server -> client
USER_LEAVE_INFORM = "user.leave.inform" 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_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 control
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload) PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
@@ -18,8 +19,8 @@ class DraftMessage(StrEnum):
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload) PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
# Status / sync # Status / sync
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server DRAFT_STATUS_REQUEST = "draft.status.request" # client -> server
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state) DRAFT_STATUS_INFORM = "draft.status.sync.inform" # server -> client (full/partial state)
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request" DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm" DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
@@ -31,7 +32,10 @@ class DraftMessage(StrEnum):
# Bidding (examples, adjust to your flow) # Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at) BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_START_REQUEST = "bid.start.request" # 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_REQUEST = "bid.place.request" # client -> server (amount)
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_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
BID_END_INFORM = "bid.end.inform" # server -> client (winner) BID_END_INFORM = "bid.end.inform" # server -> client (winner)

View File

@@ -4,27 +4,21 @@ from django.core.exceptions import PermissionDenied
from boxofficefantasy.models import League, Season from boxofficefantasy.models import League, Season
from boxofficefantasy.views import parse_season_slug from boxofficefantasy.views import parse_season_slug
from draft.models import DraftSession, DraftSessionParticipant from draft.models import DraftSession, DraftSessionParticipant
from django.core.cache import cache
import asyncio
from django.contrib.auth.models import User from django.contrib.auth.models import User
from draft.constants import ( from draft.constants import (
DraftMessage, DraftMessage,
DraftPhase, DraftPhase,
DraftGroupChannelNames, DraftGroupChannelNames,
) )
from draft.state import DraftCacheKeys, DraftStateManager from draft.state import DraftStateManager, DraftStateException
from typing import Any from typing import Any
import logging import logging
logger = logging.getLogger(__name__) # __name__ = module path logger = logging.getLogger(__name__) # __name__ = module path
import random
class DraftConsumerBase(AsyncJsonWebsocketConsumer): class DraftConsumerBase(AsyncJsonWebsocketConsumer):
group_names: DraftGroupChannelNames group_names: DraftGroupChannelNames
cache_keys: DraftCacheKeys
draft_state: DraftStateManager draft_state: DraftStateManager
user: User user: User
@@ -39,7 +33,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
) )
self.group_names = DraftGroupChannelNames(draft_hashid) self.group_names = DraftGroupChannelNames(draft_hashid)
self.cache_keys = DraftCacheKeys(draft_hashid)
self.draft_state = DraftStateManager(self.draft_session) self.draft_state = DraftStateManager(self.draft_session)
self.user = self.scope["user"] self.user = self.scope["user"]
@@ -49,8 +42,8 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
{ {
"type": "direct.message", "type": "direct.message",
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT, "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"current_user": self.user.username} "payload": {"current_user": self.user.username},
} },
) )
await self.close() await self.close()
await self.channel_layer.group_send( await self.channel_layer.group_send(
@@ -58,12 +51,13 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
{ {
"type": "broadcast.admin", "type": "broadcast.admin",
"subtype": DraftMessage.PARTICIPANT_JOIN_REJECT, "subtype": DraftMessage.PARTICIPANT_JOIN_REJECT,
"payload":{"user": self.user.username} "payload": {"user": self.user.username},
}, },
) )
return return
else: else:
await self.accept() await self.accept()
self.draft_state.connect_participant(self.user.username)
await self.channel_layer.group_add( await self.channel_layer.group_add(
self.group_names.session, self.channel_name self.group_names.session, self.channel_name
) )
@@ -75,14 +69,6 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"payload": {"user": self.user.username}, "payload": {"user": self.user.username},
}, },
) )
await self.channel_layer.send(
self.channel_name,
{
"type": "direct.message",
"subtype": DraftMessage.STATUS_SYNC_INFORM,
"payload": self.get_draft_status(),
},
)
await self.channel_layer.send( await self.channel_layer.send(
self.channel_name, self.channel_name,
{ {
@@ -91,20 +77,61 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
"payload": {"user": self.user.username}, "payload": {"user": self.user.username},
}, },
) )
await self.broadcast_state()
async def should_accept_user(self) -> bool: async def should_accept_user(self) -> bool:
return self.user.is_authenticated return self.user.is_authenticated
async def receive_json(self, content): async def receive_json(self, content):
logger.info(f"receiving message {content}")
event_type = content.get("type") event_type = content.get("type")
if event_type == DraftMessage.STATUS_SYNC_REQUEST: if event_type == DraftMessage.DRAFT_STATUS_REQUEST:
await self.send_json( await self.send_json(
{ {
"type": DraftMessage.STATUS_SYNC_INFORM, "type": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.get_draft_status(), "payload": self.get_draft_status(),
} }
) )
# --- 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,
{
"type": "direct.message",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
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,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_STATUS_INFORM,
"payload": self.draft_state.to_dict(),
},
)
# Broadcast Handlers # Broadcast Handlers
async def direct_message(self, event): async def direct_message(self, event):
await self._dispatch_broadcast(event) await self._dispatch_broadcast(event)
@@ -121,20 +148,22 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
# === Methods === # === Methods ===
def get_draft_status(self) -> dict[str, Any]: def get_draft_status(self) -> dict[str, Any]:
return { return
**self.draft_state.get_summary(),
"user": self.user.username,
"participants": [user.username for user in self.draft_participants],
}
# === DB Access === # === DB Access ===
@database_sync_to_async @database_sync_to_async
def get_draft_session(self, draft_session_id_hashed) -> DraftSession: def get_draft_session(self, draft_session_id_hashed) -> DraftSession:
draft_session_id = DraftSession.decode_id(draft_session_id_hashed) draft_session_id = DraftSession.decode_id(draft_session_id_hashed)
if draft_session_id: if draft_session_id:
draft_session = DraftSession.objects.select_related( draft_session = (
"season", "season__league", "settings" DraftSession.objects.select_related(
).get(pk=draft_session_id) "season",
"season__league",
"settings",
)
.prefetch_related("participants")
.get(pk=draft_session_id)
)
else: else:
raise Exception() raise Exception()
@@ -155,69 +184,32 @@ class DraftAdminConsumer(DraftConsumerBase):
await self.channel_layer.group_add(self.group_names.admin, self.channel_name) await self.channel_layer.group_add(self.group_names.admin, self.channel_name)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
async def receive_json(self, content): async def receive_json(self, content):
await super().receive_json(content) await super().receive_json(content)
logger.info(f"Receive message {content}") logger.info(f"Receive message {content}")
event_type = content.get("type") event_type = content.get("type")
if (
event_type == DraftMessage.PHASE_CHANGE_REQUEST
and content.get("destination") == DraftPhase.DETERMINE_ORDER
):
await self.determine_draft_order()
if ( match event_type:
event_type == DraftMessage.PHASE_CHANGE_REQUEST case DraftMessage.PHASE_CHANGE_REQUEST:
and content.get("destination") == DraftPhase.NOMINATING destination = content.get('destination')
): match destination:
await self.start_nominate() case DraftPhase.DETERMINE_ORDER:
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
if event_type == DraftMessage.DRAFT_INDEX_ADVANCE_REQUEST: self.draft_state.determine_draft_order()
self.draft_state.draft_index_advance()
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": "broadcast.session", "type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM, "subtype": DraftMessage.ORDER_DETERMINE_CONFIRM,
"payload": self.draft_state.get_summary(), "payload": {"draft_order": self.draft_state.draft_order},
}, },
) )
await self.broadcast_state()
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST: case DraftPhase.NOMINATING:
movie_id = content.get('payload',{}).get('movie_id')
user = content.get('payload',{}).get('user')
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie'],
"nominating_participant": user
}
}
)
if event_type == DraftMessage.BID_START_REQUEST:
self.draft_state.start_timer()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.BID_START_INFORM,
"payload": {
"current_movie": self.draft_state.get_summary()['current_movie'],
"bidding_duration": self.draft_state.settings.bidding_duration,
"bidding_timer_end": self.draft_state.get_timer_end(),
"bidding_timer_start": self.draft_state.get_timer_start()
}
}
)
def should_accept_user(self):
return super().should_accept_user() and self.user.is_staff
# === Draft logic ===
async def start_nominate(self):
await self.set_draft_phase(DraftPhase.NOMINATING) await self.set_draft_phase(DraftPhase.NOMINATING)
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
@@ -227,26 +219,72 @@ class DraftAdminConsumer(DraftConsumerBase):
"payload": {"phase": self.draft_state.phase}, "payload": {"phase": self.draft_state.phase},
}, },
) )
await self.broadcast_state()
async def determine_draft_order(self): case DraftPhase.BIDDING:
draft_order = self.draft_state.determine_draft_order(self.draft_participants) await self.set_draft_phase(DraftPhase.BIDDING)
self.draft_state.draft_index = 0
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
next_picks = self.draft_state.next_picks(include_current=True)
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.session, self.group_names.session,
{ {
"type": "broadcast.session", "type": "broadcast.session",
"subtype": DraftMessage.ORDER_DETERMINE_CONFIRM, "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()
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.DRAFT_INDEX_ADVANCE_CONFIRM,
"payload": {"draft_index": self.draft_state.draft_index},
},
)
await self.broadcast_state()
case DraftMessage.NOMINATION_SUBMIT_REQUEST:
movie_id = content.get("payload", {}).get("movie_id")
user = content.get("payload", {}).get("user")
self.draft_state.start_nomination(movie_id)
await self.channel_layer.group_send(
self.group_names.session,
{
"type": "broadcast.session",
"subtype": DraftMessage.NOMINATION_CONFIRM,
"payload": { "payload": {
"draft_order": draft_order, "current_movie": self.draft_state["current_movie"],
"draft_index": self.draft_state.draft_index, "nominating_participant": user,
"current_pick": next_picks[0],
"next_picks": next_picks[1:]
}, },
}, },
) )
await self.broadcast_state()
case DraftMessage.BID_START_REQUEST:
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 ===
async def set_draft_phase(self, destination: DraftPhase): async def set_draft_phase(self, destination: DraftPhase):
self.draft_state.phase = destination self.draft_state.phase = destination
@@ -278,7 +316,9 @@ class DraftParticipantConsumer(DraftConsumerBase):
"subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM, "subtype": DraftMessage.PARTICIPANT_JOIN_CONFIRM,
"payload": { "payload": {
"user": self.user.username, "user": self.user.username,
"connected_participants": self.draft_state.connected_participants, "connected_participants": list(
self.draft_state.connected_participants
),
}, },
}, },
) )
@@ -296,22 +336,26 @@ class DraftParticipantConsumer(DraftConsumerBase):
"subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM, "subtype": DraftMessage.PARTICIPANT_LEAVE_INFORM,
"payload": { "payload": {
"user": self.user.username, "user": self.user.username,
"connected_participants": self.draft_state.connected_participants, "connected_participants": list(
self.draft_state.connected_participants
),
}, },
}, },
) )
await self.broadcast_state()
await super().disconnect(close_code) await super().disconnect(close_code)
self.draft_state.disconnect_participant(self.user.username) self.draft_state.disconnect_participant(self.user.username)
await self.channel_layer.group_discard( await self.channel_layer.group_discard(
self.group_names.session, self.channel_name self.group_names.session, self.channel_name
) )
def should_accept_user(self): def should_accept_user(self):
return super().should_accept_user() and self.user in self.draft_participants return super().should_accept_user() and self.user in self.draft_participants
async def receive_json(self, content): async def receive_json(self, content):
await super().receive_json(content) await super().receive_json(content)
event_type = content.get('type') event_type = content.get("type")
if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST: if event_type == DraftMessage.NOMINATION_SUBMIT_REQUEST:
await self.channel_layer.group_send( await self.channel_layer.group_send(
self.group_names.admin, self.group_names.admin,
@@ -319,12 +363,34 @@ class DraftParticipantConsumer(DraftConsumerBase):
"type": "broadcast.admin", "type": "broadcast.admin",
"subtype": event_type, "subtype": event_type,
"payload": { "payload": {
"movie_id": content.get('payload',{}).get('id'), "movie_id": content.get("payload", {}).get("movie_id"),
"user": content.get('payload',{}).get('user') "user": content.get("payload", {}).get("user"),
} },
} },
) )
if event_type == DraftMessage.BID_PLACE_REQUEST:
bid_amount = content.get("payload", {}).get("bid_amount")
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 === # === Broadcast handlers ===
@@ -333,11 +399,8 @@ class DraftParticipantConsumer(DraftConsumerBase):
# === Draft === # === Draft ===
async def nominate(self, movie_title): ...
async def place_bid(self, amount, user): ... # === DB Access ===
# === Example DB Access ===
@database_sync_to_async @database_sync_to_async
def add_draft_participant(self): def add_draft_participant(self):

View File

@@ -1,137 +1,137 @@
from django.core.cache import cache from django.core.cache import cache, BaseCache
import json import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from boxofficefantasy.models import Movie from boxofficefantasy.models import Movie
from django.contrib.auth.models import User from django.contrib.auth.models import User
from draft.constants import DraftPhase from draft.constants import DraftPhase
from draft.models import DraftSession from draft.models import DraftSession, DraftSessionSettings
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple
import random import random
class DraftCacheKeys: class DraftStateException(Exception):
def __init__(self, id): """Raised when an action is not allowed due to the current draft state or phase."""
self.prefix = f"draft:{id}" pass
@property class DraftCache:
def admins(self): phase: str
return f"{self.prefix}:admins" draft_order: str
draft_index: str
current_movie: str
bids: str
bid_timer_start: str
bid_timer_end: str
connected_participants: str
@property _cached_properties = {
def participants(self): "participants",
return f"{self.prefix}:participants" "connected_participants",
"phase",
"draft_order",
"draft_index",
"current_movie",
"bids",
"bid_timer_start",
"bid_timer_end",
}
@property def __init__(self, draft_id: str, cache: BaseCache = cache):
def users(self): super().__setattr__("_cache", self._load_cache(cache))
return f"{self.prefix}:users" super().__setattr__("_prefix", f"draft:{draft_id}:")
@property def _load_cache(self, cache) -> BaseCache:
def connected_users(self): return cache
return f"{self.prefix}:connected_users"
@property def _save_cache(self) -> None:
def phase(self): # Django cache saves itself
return f"{self.prefix}:phase" return
@property def __getattr__(self, name: str) -> Any:
def draft_order(self): if name == "_prefix": return super().__getattribute__('_prefix')
return f"{self.prefix}:draft_order" if name in self._cached_properties:
return self._cache.get(self._prefix+name, None)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
@property def __setattr__(self, name: str, value: Any):
def draft_index(self): if name in self._cached_properties:
return f"{self.prefix}:draft_index" self._cache.set(self._prefix+name, value)
self._save_cache()
else:
super().__setattr__(name, value)
@property def __delattr__(self, name):
def current_movie(self): if name in self._cached_properties:
return f"{self.prefix}:current_movie" self._cache.delete(name)
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
# @property
# def state(self):
# return f"{self.prefix}:state"
# @property
# def current_movie(self):
# return f"{self.prefix}:current_movie"
@property
def bids(self):
return f"{self.prefix}:bids"
# @property
# def participants(self):
# return f"{self.prefix}:participants"
@property
def bid_timer_end(self):
return f"{self.prefix}:bid_timer_end"
@property
def bid_timer_start(self):
return f"{self.prefix}:bid_timer_start"
# def user_status(self, user_id):
# return f"{self.prefix}:user:{user_id}:status"
# def user_channel(self, user_id):
# return f"{self.prefix}:user:{user_id}:channel"
class DraftStateManager: class DraftStateManager:
_initial_phase: DraftPhase = DraftPhase.WAITING.value
def __init__(self, session: DraftSession): def __init__(self, session: DraftSession):
self.session_id = session.hashid self.session_id: str = session.hashid
self.cache = cache self.cache: DraftCache = DraftCache(self.session_id, cache)
self.keys = DraftCacheKeys(self.session_id) self.settings: DraftSessionSettings = session.settings
self._initial_phase = self.cache.get(self.keys.phase, DraftPhase.WAITING.value) self._participants = list(session.participants.all())
self.settings = session.settings
# === Phase Management === # === Phase Management ===
@property @property
def phase(self) -> str: def phase(self) -> str:
return str(self.cache.get(self.keys.phase, self._initial_phase)) return self.cache.phase or self._initial_phase
@phase.setter @phase.setter
def phase(self, new_phase: DraftPhase): def phase(self, new_phase: DraftPhase) -> None:
self.cache.set(self.keys.phase, new_phase.value) self.cache.phase = new_phase
# === Connected Users === # === Connected Users ===
@property @property
def connected_participants(self) -> list[str]: def connected_participants(self):
return json.loads(self.cache.get(self.keys.connected_users) or "[]") return set(json.loads(self.cache.connected_participants or "[]"))
def connect_participant(self, username: str): def connect_participant(self, username: str):
users = set(self.connected_participants) connected_participants = self.connected_participants
users.add(username) connected_participants.add(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users))) self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
def disconnect_participant(self, username: str): def disconnect_participant(self, username: str):
users = set(self.connected_participants) connected_participants = self.connected_participants
users.discard(username) connected_participants.discard(username)
self.cache.set(self.keys.connected_users, json.dumps(list(users))) self.cache.connected_participants = json.dumps(list(connected_participants))
return connected_participants
# === Draft Order === # === Draft Order ===
@property @property
def draft_order(self): def draft_order(self):
return json.loads(self.cache.get(self.keys.draft_order,"[]")) return json.loads(self.cache.draft_order or "[]")
@draft_order.setter @draft_order.setter
def draft_order(self, draft_order: list[str]): def draft_order(self, draft_order: list[str]):
if not isinstance(draft_order, list): if not isinstance(draft_order, list):
return return
self.cache.set(self.keys.draft_order,json.dumps(draft_order)) self.cache.draft_order = json.dumps(draft_order)
def determine_draft_order(self, users: list[User]): def determine_draft_order(self) -> List[User]:
self.phase = DraftPhase.DETERMINE_ORDER
self.draft_index = 0
draft_order = random.sample( draft_order = random.sample(
users, len(users) list(self._participants), len(self._participants)
) )
self.draft_order = [user.username for user in draft_order] self.draft_order = [user.username for user in draft_order]
return self.draft_order return self.draft_order
@property @property
def draft_index(self): def draft_index(self):
return self.cache.get(self.keys.draft_index,0) draft_index = self.cache.draft_index
if not draft_index:
draft_index = 0
self.cache.draft_index = draft_index
return self.cache.draft_index
@draft_index.setter @draft_index.setter
def draft_index(self, draft_index: int): def draft_index(self, draft_index: int):
self.cache.set(self.keys.draft_index, int(draft_index)) self.cache.draft_index = draft_index
def draft_index_advance(self, n: int = 1): def draft_index_advance(self, n: int = 1):
self.draft_index += n self.draft_index += n
@@ -171,50 +171,93 @@ class DraftStateManager:
# === Current Nomination / Bid === # === Current Nomination / Bid ===
def start_nomination(self, movie_id: int): def start_nomination(self, movie_id: int):
self.cache.set(self.keys.current_movie, movie_id) self.cache.current_movie = movie_id
self.cache.delete(self.keys.bids) self.cache.bids = []
def place_bid(self, user_id: int, amount: int): def place_bid(self, user: User, amount: int|str):
if isinstance(amount, str):
amount = int(amount)
bids = self.get_bids() bids = self.get_bids()
bids[user_id] = amount user_state = self.user_state(user)
self.cache.set(self.keys.bids, json.dumps(bids)) 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: def get_bids(self) -> dict:
return json.loads(self.cache.get(self.keys.bids) or "{}") return json.loads(self.cache.bids or "[]")
def current_movie(self) -> Movie | None: def current_movie(self) -> Movie | None:
movie_id = self.cache.get(self.keys.current_movie) movie_id = self.cache.current_movie
return Movie.objects.filter(pk=movie_id).first() if movie_id else None return movie_id if movie_id else None
def start_timer(self): 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 seconds = self.settings.bidding_duration
start_time = time.time() start_time = time.time()
end_time = start_time + seconds end_time = start_time + seconds
self.cache.set(self.keys.bid_timer_end, end_time) self.cache.bid_timer_end = end_time
self.cache.set(self.keys.bid_timer_start, start_time) self.cache.bid_timer_start = start_time
def get_timer_end(self) -> str | None: def get_timer_end(self) -> str | None:
return self.cache.get(self.keys.bid_timer_end) return self.cache.bid_timer_end
def get_timer_start(self) -> str | None: def get_timer_start(self) -> str | None:
return self.cache.get(self.keys.bid_timer_start) return self.cache.bid_timer_start
# === Sync Snapshot === # === Sync Snapshot ===
def get_summary(self) -> dict: def to_dict(self) -> dict:
picks = self.next_picks(include_current=True) picks = self.next_picks(include_current=True)
return { return {
"phase": self.phase, "phase": self.phase,
"draft_order": self.draft_order, "draft_order": self.draft_order,
"draft_index": self.draft_index, "draft_index": self.draft_index,
"connected_participants": self.connected_participants, "connected_participants": list(self.connected_participants),
"current_movie": self.cache.get(self.keys.current_movie), "current_movie": self.cache.current_movie,
# "bids": self.get_bids(), "awards": [],
"bids": self.get_bids(),
"bidding_timer_end": self.get_timer_end(), "bidding_timer_end": self.get_timer_end(),
"bidding_timer_start": self.get_timer_start(), "bidding_timer_start": self.get_timer_start(),
"current_pick": picks[0] if picks else None, "current_pick": picks[0] if picks else None,
"next_picks": picks[1:] if picks else [] "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()
def keys(self):
# return an iterable of keys
return self.to_dict().keys()
def __getitem__(self, key):
return self.to_dict()[key]
def __iter__(self):
# used for `dict(self.draft_state)` and iteration
return iter(self.to_dict())
def __len__(self):
return len(self.to_dict())
OrderType = Literal["snake", "linear"] OrderType = Literal["snake", "linear"]
def _round_and_pick(overall: int, n: int) -> Tuple[int, int]: def _round_and_pick(overall: int, n: int) -> Tuple[int, int]:
"""overall -> (round_1_based, pick_in_round_1_based)""" """overall -> (round_1_based, pick_in_round_1_based)"""

View File

@@ -3,6 +3,11 @@
{% load static %} {% load static %}
<script> <script>
window.draftSessionId = "{{ draft_id_hashed }}" window.draftSessionId = "{{ draft_id_hashed }}"
window.isAdmin = "{{user.is_staff}}"
console.log("{{user}}")
</script> </script>
{% 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> <div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% endblock body %} {% endblock body %}

View File

@@ -1,10 +1,8 @@
{% extends "base.dj.html" %} {% extends "base.dj.html" %}
{% block content %} {% block body %}
<h1>Draft Room: {{ league.name }} {{ season.label }} {{ season.year }}</h1>
{% load static %} {% load static %}
<script> <script>
window.draftSessionId = "{{ draft_id_hashed }}" window.draftSessionId = "{{ draft_id_hashed }}"
</script> </script>
<div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div> <div id="draft-admin-root" data-draft-id="{{ draft_id_hashed }}"></div>
{% endblock body %}
{% endblock %}

View File

@@ -6,6 +6,7 @@ app_name = "draft"
urlpatterns = [ urlpatterns = [
# path("", views.draft_room, name="room"), # path("", views.draft_room, name="room"),
path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"), path("session/<str:draft_session_id_hashed>/", views.draft_room, name="session"),
path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"), path("session/<str:draft_session_id_hashed>/debug", views.draft_room_debug, name="session"),
# path("session/<str:draft_session_id_hashed>/<str:subpage>", views.draft_room, name="admin_session"),
# path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"), # path("<slug:league_slug>/<slug:season_slug>/", views.draft_room_list, name="room"),
] ]

View File

@@ -6,28 +6,22 @@ from django.contrib.auth.decorators import login_required
from boxofficefantasy_project.utils import decode_id from boxofficefantasy_project.utils import decode_id
@login_required(login_url='/login/') @login_required(login_url='/login/')
def draft_room(request, league_slug=None, season_slug=None, draft_session_id_hashed=None, subpage=""): def draft_room(request, draft_session_id_hashed=None):
if draft_session_id_hashed: if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed) draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id) draft_session = get_object_or_404(DraftSession, id=draft_session_id)
league = draft_session.season.league league = draft_session.season.league
season = draft_session.season season = draft_session.season
elif league_slug and season_slug:
raise NotImplementedError
league = get_object_or_404(League, slug=league_slug)
label, year = parse_season_slug(season_slug)
season = get_object_or_404(Season, league=league, label__iexact=label, year=year)
draft_session = get_object_or_404(DraftSession, season=season)
context = { context = {
"draft_id_hashed": draft_session.hashid, "draft_id_hashed": draft_session.hashid,
"league": league, "league": league,
"season": season, "season": season,
} }
if subpage == "admin":
return render(request, "draft/room_admin.dj.html", context)
elif subpage == "debug":
return render(request, "draft/room_debug.dj.html", context)
else:
return render(request, "draft/room.dj.html", context) return render(request, "draft/room.dj.html", context)
def draft_room_debug(request, draft_session_id_hashed=None):
if draft_session_id_hashed:
draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
return render(request, "draft/room_debug.dj.html", {"draft_id_hashed": draft_session.hashid,})

21
draft_cache.json Normal file
View File

@@ -0,0 +1,21 @@
{
"participants": [
"Alice",
"Bob"
],
"phase": "nominating",
"draft_order": [
"Bob",
"Alice"
],
"draft_index": 0,
"current_movie": "The Matrix",
"bids": [
{
"Alice": 10
},
{
"Bob": 12
}
]
}

View File

@@ -7,6 +7,7 @@
"dependencies": { "dependencies": {
"bootstrap": "^5.3.7", "bootstrap": "^5.3.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
@@ -1709,6 +1710,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz",
"integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.2", "version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
@@ -2221,12 +2231,89 @@
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/popperjs" "url": "https://opencollective.com/popperjs"
} }
}, },
"node_modules/@react-aria/ssr": {
"version": "3.9.10",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz",
"integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@restart/hooks": {
"version": "0.4.16",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
"integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@restart/ui": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz",
"integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.26.0",
"@popperjs/core": "^2.11.8",
"@react-aria/ssr": "^3.5.0",
"@restart/hooks": "^0.5.0",
"@types/warning": "^3.0.3",
"dequal": "^2.0.3",
"dom-helpers": "^5.2.0",
"uncontrollable": "^8.0.4",
"warning": "^4.0.3"
},
"peerDependencies": {
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
}
},
"node_modules/@restart/ui/node_modules/@restart/hooks": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz",
"integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@restart/ui/node_modules/uncontrollable": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz",
"integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.14.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2375,6 +2462,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -2389,6 +2482,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": {
"version": "19.1.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/retry": { "node_modules/@types/retry": {
"version": "0.12.2", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -2439,6 +2550,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/warning": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
"integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==",
"license": "MIT"
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.1", "version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3169,6 +3286,12 @@
"node": ">=6.0" "node": ">=6.0"
} }
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/clone-deep": { "node_modules/clone-deep": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -3372,6 +3495,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3435,6 +3564,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/destroy": { "node_modules/destroy": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -3480,6 +3618,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4232,6 +4380,15 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -4768,6 +4925,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5084,6 +5250,30 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types-extra": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz",
"integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==",
"license": "MIT",
"dependencies": {
"react-is": "^16.3.2",
"warning": "^4.0.0"
},
"peerDependencies": {
"react": ">=0.14.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -5172,6 +5362,37 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-bootstrap": {
"version": "2.10.10",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.10.tgz",
"integrity": "sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.24.7",
"@restart/hooks": "^0.4.9",
"@restart/ui": "^1.9.4",
"@types/prop-types": "^15.7.12",
"@types/react-transition-group": "^4.4.6",
"classnames": "^2.3.2",
"dom-helpers": "^5.2.1",
"invariant": "^2.2.4",
"prop-types": "^15.8.1",
"prop-types-extra": "^1.1.0",
"react-transition-group": "^4.4.5",
"uncontrollable": "^7.2.1",
"warning": "^4.0.3"
},
"peerDependencies": {
"@types/react": ">=16.14.8",
"react": ">=16.14.0",
"react-dom": ">=16.14.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -5185,6 +5406,34 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -6123,7 +6372,6 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/type-is": { "node_modules/type-is": {
@@ -6140,6 +6388,21 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/uncontrollable": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
"integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.6.3",
"@types/react": ">=16.9.11",
"invariant": "^2.2.4",
"react-lifecycles-compat": "^3.0.4"
},
"peerDependencies": {
"react": ">=15.0.0"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.8.0", "version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
@@ -6269,6 +6532,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.4.4", "version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",

View File

@@ -18,6 +18,7 @@
"dependencies": { "dependencies": {
"bootstrap": "^5.3.7", "bootstrap": "^5.3.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
} }
} }

View File

@@ -1,12 +1,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx"; import { useWebSocket } from "./components/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx"; import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js'; import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { jsxs } from "react/jsx-runtime"; import { jsxs } from "react/jsx-runtime";
@@ -14,7 +12,6 @@ import { jsxs } from "react/jsx-runtime";
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => { const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return ( return (
<div className="draft-phase-container"> <div className="draft-phase-container">
<label>Phase</label>
<div className="d-flex"> <div className="d-flex">
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div> <div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
<ol> <ol>
@@ -41,7 +38,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
useEffect(() => { useEffect(() => {
fetchDraftDetails(draftSessionId) fetchDraftDetails(draftSessionId)
.then((data) => { .then((data) => {
console.log("Fetched draft data", data)
setDraftDetails(data) setDraftDetails(data)
}) })
}, []) }, [])
@@ -102,7 +98,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
else if (target == "previous" && originPhaseIndex > 0) { else if (target == "previous" && originPhaseIndex > 0) {
destination = DraftPhasesOrdered[originPhaseIndex - 1] destination = DraftPhasesOrdered[originPhaseIndex - 1]
} }
console.log(destination)
socket.send( socket.send(
JSON.stringify( JSON.stringify(
{ type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination } { type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
@@ -140,30 +135,18 @@ export const DraftAdmin = ({ draftSessionId }) => {
} }
return ( return (
<div className="container draft-panel admin"> <div id="draft-admin-bar">
<div className="d-flex justify-content-between border-bottom mb-2 p-1"> <div>
<h3>Draft Panel</h3> <button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light mx-1">
<div className="d-flex gap-1">
<WebSocketStatus socket={socket} />
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light">
<i className="bi bi-arrow-clockwise"></i> <i className="bi bi-arrow-clockwise"></i>
</button> </button>
<button onClick={handleAdvanceDraft} className="btn btn-primary mx-1">Advance Index</button>
<button onClick={handleStartBidding} className="btn btn-primary mx-1">Start Bidding</button>
</div> </div>
<div>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</div> </div>
<ParticipantList
currentUser = {currentUser}
draftState={draftState}
draftDetails={draftDetails}
isAdmin={true}
/>
<div className="d-flex gap-1 m-1">
<button onClick={handleAdvanceDraft} className="btn btn-primary">Advance Draft</button>
<button onClick={handleStartBidding} className="btn btn-primary">Start Bidding</button>
</div>
<DraftMoviePool draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,169 @@
// DraftAdmin.jsx
import React, { useEffect, useState, useRef } from "react";
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { WebSocketStatus } from "./components/WebSocketStatus.jsx";
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, handleUserStatusMessages, handleUserIdentifyMessages } from './utils.js'
// import { Collapse } from 'bootstrap/dist/js/bootstrap.bundle.min.js';
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([]);
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
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]);
function submitBidRequest(event) {
event.preventDefault()
const form = event.target
const formData = new FormData(form)
socket.send(JSON.stringify({
type: DraftMessage.BID_PLACE_REQUEST,
payload: {
bid_amount: formData.get('bidAmount'),
user: currentUser
}
}))
}
const currentUserStatus = userStatus.find(u => u.user == currentUser)
const currentMovie = movies.find(i => draftState.current_movie == i.id)
return (
<div className={`wrapper`}>
<section id="draft-live">
<div className="panel">
<header className="panel-header">
<div className="panel-title"><span>Draft Live</span></div>
<div className="d-flex gap-1">
<div className="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div className="panel-body">
<div>
<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>
</section>
<section className="panel" id="draft-slate">
<header className="panel-header">
<div className="panel-title"><span>Films</span></div>
</header>
<div className="panel-body">
<div className="movie-filters"></div>
<DraftMoviePool currentUserStatus={currentUserStatus} currentUser={currentUser} socket={socket} isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section className="panel my-team">
<header className="panel-header">
<div className="panel-title"><span>My Team</span></div>
</header>
<div className="panel-body">
<ul className="team-movie-list list-group">
<li className="team-movie-item list-group-item"></li>
</ul>
<div className="budget-status"></div>
</div>
</section>
<section className="panel teams">
<header className="panel-header">
<div className="panel-title"><span>Teams</span></div>
</header>
<div className="panel-body">
<ParticipantList currentUser={currentUser} className="team-list" draftDetails={draftDetails} draftState={draftState} isAdmin={isAdmin}></ParticipantList>
</div>
</section>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";; import React, { useEffect, useState } from "react";;
import { useWebSocket } from "./common/WebSocketContext.jsx"; import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js'; import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./common/utils.js" import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
export const DraftDebug = ({ draftSessionId }) => { export const DraftDebug = ({ draftSessionId }) => {
const [draftState, setDraftState] = useState({}) const [draftState, setDraftState] = useState({})

View File

@@ -1,23 +0,0 @@
import React from "react";
import { isEmptyObject } from "./utils";
export const DraftMoviePool = ({ isParticipant, draftDetails, draftState }) => {
if(isEmptyObject(draftDetails)) {return}
const {movies} = draftDetails
const {current_movie} = draftState
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>
</div>
)
}

View File

@@ -1,32 +0,0 @@
import React from "react";
import { fetchDraftDetails, isEmptyObject } from "../common/utils.js"
export const ParticipantList = ({ isAdmin, draftState, draftDetails, currentUser }) => {
if (isEmptyObject(draftState) || isEmptyObject(draftDetails)) { console.warn('empty draft state', draftState); 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
className={
`ms-2 stop-light ${connected_participants.includes(p?.username) ? "success" : "danger"}`
}
></div>
) : null}
</li>
))}
</ListTag>
</div>
)
}

View File

@@ -1,24 +1,26 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export function DraftCountdownClock({ endTime, onFinish }) { export function DraftCountdownClock({ draftState }) {
// endTime is in seconds (Unix time) const { bidding_timer_end, onFinish } = draftState;
const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000)); const getTimeLeft = (et) => Math.max(0, Math.floor(et - Date.now() / 1000));
const [timeLeft, setTimeLeft] = useState(getTimeLeft(endTime)); const [timeLeft, setTimeLeft] = useState(getTimeLeft(bidding_timer_end));
useEffect(() => { 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(); if (onFinish) onFinish();
return; return;
} }
const timer = setInterval(() => { const timer = setInterval(() => {
const t = getTimeLeft(endTime); const t = getTimeLeft(bidding_timer_end);
setTimeLeft(t); setTimeLeft(t);
if (t <= 0 && onFinish) onFinish(); if (t <= 0 && onFinish) onFinish();
}, 100); }, 100);
return () => clearInterval(timer); return () => clearInterval(timer);
// eslint-disable-next-line }, [bidding_timer_end, onFinish]);
}, [endTime, onFinish, timeLeft]);
const minutes = Math.floor(timeLeft / 60); const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60; const secs = timeLeft % 60;
@@ -27,7 +29,7 @@ export function DraftCountdownClock({ endTime, onFinish }) {
return ( return (
<div className="countdown-clock"> <div className="countdown-clock">
<span> <span>
{minutes}:{pad(secs)} {!isNaN(minutes) && !isNaN(secs) ? `${minutes}:${pad(secs)}` : "0:00"}
</span> </span>
</div> </div>
); );

View File

@@ -0,0 +1,87 @@
import React from "react";
import { isEmptyObject } from "../utils";
import { DraftMessage } from "../constants";
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">
<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

@@ -0,0 +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)) { 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 (
<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>
)
}

View File

@@ -6,6 +6,9 @@ export const WebSocketStatus = ({ socket }) => {
console.log('socket changed', socket) console.log('socket changed', socket)
if (!socket) return; if (!socket) return;
// Set initial state according to readyState
setIsConnected(socket.readyState === 1);
const handleOpen = () => { console.log('socket open'); setIsConnected(true) }; const handleOpen = () => { console.log('socket open'); setIsConnected(true) };
const handleClose = () => { console.log('socket close'); setIsConnected(false) }; const handleClose = () => { console.log('socket close'); setIsConnected(false) };
const handleError = () => { console.log('socket error'); setIsConnected(false) }; const handleError = () => { console.log('socket error'); setIsConnected(false) };

View File

@@ -10,18 +10,22 @@ export const DraftMessage = {
USER_JOIN_INFORM: "user.join.inform", USER_JOIN_INFORM: "user.join.inform",
USER_LEAVE_INFORM: "user.leave.inform", USER_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.inform", USER_IDENTIFICATION_INFORM: "user.identification.inform",
USER_STATE_INFORM: "user.state.inform",
PHASE_CHANGE_INFORM: "phase.change.inform", PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request", PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm", PHASE_CHANGE_CONFIRM: "phase.change.confirm",
STATUS_SYNC_REQUEST: "status.sync.request", DRAFT_STATUS_REQUEST: "draft.status.request",
STATUS_SYNC_INFORM: "status.sync.inform", DRAFT_STATUS_INFORM: "draft.status.sync.inform",
DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request", DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm", DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
ORDER_DETERMINE_REQUEST: "order.determine.request", ORDER_DETERMINE_REQUEST: "order.determine.request",
ORDER_DETERMINE_CONFIRM: "order.determine.confirm", ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform", BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request", BID_START_REQUEST: "bid.start.request",
BID_START_REJECT: "bid.start.reject",
BID_PLACE_REQUEST: "bid.place.request", BID_PLACE_REQUEST: "bid.place.request",
BID_PLACE_REJECT: "bid.place.reject",
BID_PLACE_CONFIRM: "bid.place.confirm",
BID_UPDATE_INFORM: "bid.update.inform", BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform", BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request", NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",

View File

@@ -1,164 +0,0 @@
// DraftAdmin.jsx
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { DraftMessage, DraftPhaseLabel, DraftPhases } from '../constants.js';
import { fetchDraftDetails, handleUserIdentifyMessages, isEmptyObject } from "../common/utils.js";
import { DraftMoviePool } from "../common/DraftMoviePool.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { handleDraftStatusMessages } from '../common/utils.js'
const NominateMenu = ({ socket, draftState, draftDetails, currentUser }) => {
if (!socket || isEmptyObject(draftDetails) || isEmptyObject(draftState)) return;
const currentDrafter = draftState.draft_order[draftState.draft_index]
if (currentUser != currentDrafter) return;
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
}
}))
}
return (
<div>
<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>
)
}
export const DraftParticipant = ({ draftSessionId }) => {
const socket = useWebSocket();
const [draftState, setDraftState] = useState({});
const [draftDetails, setDraftDetails] = useState({});
const [currentUser, setCurrentUser] = useState(null);
const [movies, setMovies] = useState([]);
console.log(socket)
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setMovies(data.movies)
setDraftDetails(data)
})
}, [draftSessionId])
useEffect(() => {
if (!socket) return;
socket.onclose = (event) => {
console.log('Websocket Closed')
}
}, [socket])
useEffect(() => {
if (!socket) return;
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler);
socket.removeEventListener('message', userIdentifyMessageHandler);
};
}, [socket]);
return (
<div className="draft-participant">
<section class="panel draft-live">
<header class="panel-header d-flex justify-content-between align-items-center">
<h2 class="panel-title">Draft Live</h2>
<div class="d-flex gap-1">
<div class="phase-indicator badge bg-primary">{DraftPhaseLabel[draftState.phase]}</div>
<WebSocketStatus socket={socket} />
</div>
</header>
<div class="panel-body">
<div class="draft-live-state-container">
<DraftCountdownClock endTime={draftState.bidding_timer_end}></DraftCountdownClock>
<div class="pick-description">
{console.log("draft_state", draftState)}
<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 class="current-movie card"></div>
<div class="bid-controls btn-group"></div>
<ParticipantList
currentUser={draftState.current_pick?.participant}
draftState={draftState}
draftDetails={draftDetails}
/>
</div>
</section>
<section class="panel draft-board">
<header class="panel-header">
<h2 class="panel-title">Draft Board</h2>
</header>
<div class="panel-body">
<div class="current-movie-detail card"></div>
<div class="movie-filters"></div>
<DraftMoviePool isParticipant={true} draftDetails={draftDetails} draftState={draftState}></DraftMoviePool>
</div>
</section>
<section class="panel my-team">
<header class="panel-header">
<h2 class="panel-title">My Team</h2>
</header>
<div class="panel-body">
<ul class="team-movie-list list-group">
<li class="team-movie-item list-group-item"></li>
</ul>
<div class="budget-status"></div>
</div>
</section>
<section class="panel teams">
<header class="panel-header">
<h2 class="panel-title">Teams</h2>
</header>
<div class="panel-body">
<ul class="team-list list-group">
<li class="team-item list-group-item">
<div class="team-name fw-bold"></div>
<ul class="team-movie-list list-group list-group-flush">
<li class="team-movie-item list-group-item"></li>
</ul>
</li>
</ul>
</div>
</section>
<NominateMenu socket={socket} currentUser={currentUser} draftState={draftState} draftDetails={draftDetails}></NominateMenu>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import { DraftMessage } from "../constants"; import { DraftMessage } from "./constants";
export async function fetchDraftDetails(draftSessionId) { export async function fetchDraftDetails(draftSessionId) {
return fetch(`/api/draft/${draftSessionId}/`) return fetch(`/api/draft/${draftSessionId}/`)
@@ -37,36 +37,12 @@ export function isEmptyObject(obj) {
export const handleDraftStatusMessages = (event, setDraftState) => { export const handleDraftStatusMessages = (event, setDraftState) => {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
const { type, payload } = message; const { type, payload } = message;
console.log("Message: ", type, event?.data);
if (!payload) return; if (!payload) return;
const {
connected_participants,
phase,
draft_order,
draft_index,
current_movie,
bidding_timer_end,
bidding_timer_start,
current_pick,
next_picks
} = payload;
if (type == DraftMessage.STATUS_SYNC_INFORM) { if (type == DraftMessage.DRAFT_STATUS_INFORM) {
setDraftState(payload); setDraftState(payload);
} }
setDraftState((prev) => ({
...prev,
...(connected_participants ? { connected_participants } : {}),
...(draft_order ? { draft_order } : {}),
...(draft_index ? { draft_index } : {}),
...(phase ? { phase: Number(phase) } : {}),
...(current_movie ? { current_movie } : {}),
...(bidding_timer_end ? { bidding_timer_end: Number(bidding_timer_end) } : {}),
...(current_pick ? { current_pick } : {}),
...(next_picks ? { next_picks } : {}),
}));
}; };
export const handleUserIdentifyMessages = (event, setUser) => { export const handleUserIdentifyMessages = (event, setUser) => {
@@ -74,8 +50,16 @@ export const handleUserIdentifyMessages = (event, setUser) => {
const { type, payload } = message; const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) { if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data);
const { user } = payload; const { user } = payload;
setUser(user); 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

@@ -2,28 +2,28 @@ import './scss/styles.scss'
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { WebSocketProvider } from "./apps/draft/common/WebSocketContext.jsx"; import { WebSocketProvider } from "./apps/draft/components/WebSocketContext.jsx";
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx"; import { DraftAdmin } from "./apps/draft/DraftAdminBar.jsx";
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx' import { DraftParticipant} from './apps/draft/DraftDashboard.jsx'
import { DraftDebug} from './apps/draft/DraftDebug.jsx' import { DraftDebug} from './apps/draft/DraftDebug.jsx'
const draftAdminRoot = document.getElementById("draft-admin-root"); const draftAdminBarRoot = document.getElementById("draft-admin-bar-root");
const draftPartipantRoot = document.getElementById("draft-participant-root") const draftPartipantRoot = document.getElementById("draft-participant-root")
const draftDebugRoot = document.getElementById("draft-debug-root") const draftDebugRoot = document.getElementById("draft-debug-root")
const {draftSessionId} = window; // from backend template const {draftSessionId, isAdmin} = window; // from backend template
if (draftPartipantRoot) { if (draftPartipantRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`; const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
createRoot(draftPartipantRoot).render( createRoot(draftPartipantRoot).render(
<WebSocketProvider url={wsUrl}> <WebSocketProvider url={wsUrl}>
<DraftParticipant draftSessionId={draftSessionId} /> <DraftParticipant draftSessionId={draftSessionId} className={`${isAdmin ? 'admin':''}`}/>
</WebSocketProvider> </WebSocketProvider>
); );
} }
if (draftAdminRoot) { if (draftAdminBarRoot) {
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`; const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
createRoot(draftAdminRoot).render( createRoot(draftAdminBarRoot).render(
<WebSocketProvider url={wsUrl}> <WebSocketProvider url={wsUrl}>
<DraftAdmin draftSessionId={draftSessionId}/> <DraftAdmin draftSessionId={draftSessionId}/>
</WebSocketProvider> </WebSocketProvider>

View File

@@ -1,6 +1,9 @@
@use "../../node_modules/bootstrap/scss/bootstrap.scss"; @use "../../node_modules/bootstrap/scss/bootstrap.scss";
@use "./fonts/graphique.css"; @use "./fonts/graphique.css";
@import url("https://fonts.googleapis.com/css2?family=Bebas+Neue&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";
.navbar { .navbar {
// background-color: #582f0e; // background-color: #582f0e;
@@ -92,61 +95,168 @@
} }
} }
.participant-list-container, ol.participant-list {
.movie-pool-container {
max-width: 575.98px;
label {
@extend .fs-3;
}
@extend .list-group;
ol,
ul {
@extend .p-0;
}
ol {
@extend .list-group-numbered; @extend .list-group-numbered;
} }
ol.participant-list,
ul.participant-list {
@extend .list-group;
li { li {
@extend .list-group-item; @extend .list-group-item;
@extend .d-flex; @extend .d-flex;
@extend .justify-content-between; @extend .justify-content-between;
@extend .align-items-center; @extend .align-items-center;
span { .team-name {
@extend .me-auto; @extend .flex-grow-1;
@extend .ps-1; @extend .ps-2;
}
.team-movie-list {
li {
@extend .p-0;
}
} }
} }
.current-user { .current-user {
@extend .fw-bold;
&::after { &::after {
content: " *"; // content: " *";
font-size: 1em; // adjust as needed font-size: 1em; // adjust as needed
} }
} }
} }
.draft-participant { .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;
@extend .border-top;
@extend .border-bottom;
@extend .gap-2;
@extend .p-2;
@extend .shadow-sm;
div {
@extend .d-flex;
@extend .justify-content-center;
}
}
.admin-override {
button {
@extend .btn-warning;
}
}
#draft-participant-root {
@extend .flex-grow-1;
.wrapper:first-child {
@extend .p-2;
display: flex; display: flex;
flex-wrap: wrap; /* allow panels to wrap */ flex-wrap: wrap; /* allow panels to wrap */
gap: 1rem; /* space between panels */ gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */ justify-content: center; /* center the panels horizontally */
.panel { section {
flex: 1 1 350px; /* grow/shrink, base width */
max-width: 450px; /* never go beyond this */ max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */ min-width: 300px; /* keeps them from getting too small */
flex: 1 1 350px; /* grow/shrink, base width */
} }
.panel.draft-live { .panel {
.draft-live-state-container { @extend .border;
@extend .d-flex; @extend .shadow-sm;
.countdown-clock { @extend .rounded-2;
@extend .fs-1; header.panel-header {
@extend .p-1;
@extend .text-uppercase;
@extend .align-items-center;
@extend .border-bottom;
@extend .border-2;
@extend .border-secondary-subtle;
// background-color: $blue-100;
@extend .bg-dark;
@extend .bg-gradient;
@extend .text-light;
@extend .rounded-top-2;
.panel-title {
@extend .ms-2;
@extend .fw-bold; @extend .fw-bold;
@extend .fs-5;
}
}
}
.bids-container {
overflow: scroll;
height: 85px;
}
#draft-live {
header.panel-header {
@extend .d-flex;
@extend .justify-content-between;
}
#draft-clock {
@extend .row;
@extend .g-0;
// background-color: $green-100;
@extend .text-light;
@extend .text-bg-dark;
@extend .lh-1;
.countdown-clock {
font-family: "League Gothic";
font-size: $font-size-base * 5;
@extend .fw-bolder;
@extend .col; @extend .col;
@extend .align-content-center; @extend .align-content-center;
@extend .text-center; @extend .text-center;
} }
.pick-description { .pick-description {
@extend .col; @extend .col;
@extend .align-content-center;
}
}
div:has(.pick-list),
div:has(.bid-list) {
ul {
@extend .list-group;
}
li {
@extend .list-group-item;
}
}
.bid-status {
min-height: 50px;
}
.bid-controls {
button {
@extend .btn;
@extend .btn-primary;
}
input {
@extend .form-control;
}
} }
} }
} }

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"bootstrap": "^5.3.7",
"react-bootstrap": "^2.10.10"
}
}

0
scripts/generate_js_constants.py Normal file → Executable file
View File