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

View File

@@ -7,6 +7,18 @@
"launch": {
"version": "0.2.0",
"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",
"type": "debugpy",
@@ -22,7 +34,7 @@
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": ["boxofficefantasy_project.asgi:application", "--reload",],
"args": ["boxofficefantasy_project.asgi:application", "--reload"],
"django": true,
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"
@@ -32,10 +44,7 @@
"type": "node",
"request": "launch",
"runtimeExecutable": "npm",
"args": [
"run",
"dev"
],
"args": ["run", "dev"],
"cwd": "${workspaceFolder}/frontend",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
@@ -62,7 +71,11 @@
"compounds": [
{
"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"
}
]
@@ -152,7 +165,7 @@
"editor.defaultFormatter": "ms-python.black-formatter"
},
"[django-html]": {
"editor.defaultFormatter": "monosans.djlint",
"editor.defaultFormatter": "monosans.djlint"
},
"emmet.includeLanguages": {
"django-html": "html"
@@ -161,15 +174,13 @@
"*.dj.html": "django-html"
},
"files.exclude": {
"**/__pycache__":true,
".venv":false
"**/__pycache__": true,
".venv": false
},
"auto-close-tag.activationOnLanguage": [
"django-html"
],
"auto-close-tag.activationOnLanguage": ["django-html"],
"terminal.integrated.env.osx": {
"VSCODE_HISTFILE":"${workspaceFolder}/.venv/.term_history"
},
"VSCODE_HISTFILE": "${workspaceFolder}/.venv/.term_history"
}
// "html.autoClosingTags": true,
}
}

View File

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

View File

@@ -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.bootstrap5.js"></script>
</head>
<body>
<body class="d-flex flex-column vh-100">
<nav class="navbar justify-content-ends pe-2">
<div>
<a class="navbar-brand" href="/">
@@ -50,18 +50,22 @@
<li class="breadcrumb-item {% if forloop.last %}active{% endif %}"
aria-current="page">
{% if not forloop.last %}
<a href="{{ crumb.url }}">{{ crumb.label }}</a>{% else %}{{ crumb.label }}{% endif %}
</li>
{% endfor %}
</ol>
{% endif %}
</nav>
{% endblock breadcrumbs %}
{% block content %}{% endblock content %}
{% endblock body %}
<a href="{{ crumb.url }}">{{ crumb.label }}</a>
{% else %}
{{ crumb.label }}
{% endif %}
</li>
{% endfor %}
</ol>
{% endif %}
</nav>
{% endblock breadcrumbs %}
{% block content %}
{% endblock content %}
</main>
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>
{% endblock body %}
<footer class="text-muted text-center mt-5">
<small>&copy; Sack Lunch</small>
</footer>
</body>
</html>

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!
DEBUG = True
ALLOWED_HOSTS = ["localhost"]
ALLOWED_HOSTS = ["localhost", "kif.local"]
# 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_LEAVE_INFORM = "user.leave.inform"
USER_IDENTIFICATION_INFORM = "user.identification.inform" # server -> client (tells socket "you are X", e.g. after connect) # server -> client
USER_STATE_INFORM = "user.state.inform"
# Phase control
PHASE_CHANGE_INFORM = "phase.change.inform" # server -> client (target phase payload)
@@ -18,8 +19,8 @@ class DraftMessage(StrEnum):
PHASE_CHANGE_CONFIRM = "phase.change.confirm" # server -> client (target phase payload)
# Status / sync
STATUS_SYNC_REQUEST = "status.sync.request" # client -> server
STATUS_SYNC_INFORM = "status.sync.inform" # server -> client (full/partial state)
DRAFT_STATUS_REQUEST = "draft.status.request" # client -> server
DRAFT_STATUS_INFORM = "draft.status.sync.inform" # server -> client (full/partial state)
DRAFT_INDEX_ADVANCE_REQUEST = "draft.index.advance.request"
DRAFT_INDEX_ADVANCE_CONFIRM = "draft.index.advance.confirm"
@@ -31,7 +32,10 @@ class DraftMessage(StrEnum):
# Bidding (examples, adjust to your flow)
BID_START_INFORM = "bid.start.inform" # server -> client (movie, ends_at)
BID_START_REQUEST = "bid.start.request" # server -> client (movie, ends_at)
BID_START_REJECT = "bid.start.reject" # server -> client (movie, ends_at)
BID_PLACE_REQUEST = "bid.place.request" # client -> server (amount)
BID_PLACE_REJECT = "bid.place.reject" # server -> client (high bid)
BID_PLACE_CONFIRM = "bid.place.confirm" # server -> client (high bid)
BID_UPDATE_INFORM = "bid.update.inform" # server -> client (high bid)
BID_END_INFORM = "bid.end.inform" # server -> client (winner)

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ app_name = "draft"
urlpatterns = [
# 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>/<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"),
]

View File

@@ -6,28 +6,22 @@ from django.contrib.auth.decorators import login_required
from boxofficefantasy_project.utils import decode_id
@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:
draft_session_id = decode_id(draft_session_id_hashed)
draft_session = get_object_or_404(DraftSession, id=draft_session_id)
league = draft_session.season.league
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 = {
"draft_id_hashed": draft_session.hashid,
"league": league,
"season": season,
}
return render(request, "draft/room.dj.html", context)
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)
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": {
"bootstrap": "^5.3.7",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1"
},
"devDependencies": {
@@ -1709,6 +1710,15 @@
"@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": {
"version": "7.27.2",
"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",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"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": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -2375,6 +2462,12 @@
"@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": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -2389,6 +2482,24 @@
"dev": true,
"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": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
@@ -2439,6 +2550,12 @@
"@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": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3169,6 +3286,12 @@
"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": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz",
@@ -3372,6 +3495,12 @@
"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": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -3435,6 +3564,15 @@
"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": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
@@ -3480,6 +3618,16 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -4232,6 +4380,15 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
@@ -4768,6 +4925,15 @@
"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": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5084,6 +5250,30 @@
"dev": true,
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -5172,6 +5362,37 @@
"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": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -5185,6 +5406,34 @@
"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": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -6123,7 +6372,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-is": {
@@ -6140,6 +6388,21 @@
"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": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
@@ -6269,6 +6532,15 @@
"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": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",

View File

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

View File

@@ -1,12 +1,10 @@
import React, { useEffect, useState } from "react";
import { useWebSocket } from "../common/WebSocketContext.jsx";
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
import { ParticipantList } from "../common/ParticipantList.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from '../constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "../common/utils.js"
import { DraftMoviePool } from "../common/DraftMoviePool.jsx"
import { DraftCountdownClock } from "../common/DraftCountdownClock.jsx"
import { useWebSocket } from "./components/WebSocketContext.jsx";
import { DraftMessage, DraftPhase, DraftPhaseLabel, DraftPhasesOrdered } from './constants.js';
import { fetchDraftDetails, isEmptyObject, handleDraftStatusMessages, handleUserIdentifyMessages } from "./utils.js"
import { jsxs } from "react/jsx-runtime";
@@ -14,7 +12,6 @@ import { jsxs } from "react/jsx-runtime";
const DraftPhaseDisplay = ({ draftPhase, nextPhaseHandler, prevPhaseHandler }) => {
return (
<div className="draft-phase-container">
<label>Phase</label>
<div className="d-flex">
<div className="change-phase"><button onClick={prevPhaseHandler}><i className="bi bi-chevron-left"></i></button></div>
<ol>
@@ -41,24 +38,23 @@ export const DraftAdmin = ({ draftSessionId }) => {
useEffect(() => {
fetchDraftDetails(draftSessionId)
.then((data) => {
console.log("Fetched draft data", data)
setDraftDetails(data)
})
}, [])
useEffect(()=>{
useEffect(() => {
if (!socket) return;
const openHandler = (event)=>{
const openHandler = (event) => {
console.log('Websocket Opened')
}
const closeHandler = (event)=>{
const closeHandler = (event) => {
console.log('Websocket Closed')
}
socket.addEventListener('open', openHandler );
socket.addEventListener('close', closeHandler );
return ()=>{
socket.removeEventListener('open', openHandler );
socket.removeEventListener('close', closeHandler );
socket.addEventListener('open', openHandler);
socket.addEventListener('close', closeHandler);
return () => {
socket.removeEventListener('open', openHandler);
socket.removeEventListener('close', closeHandler);
}
}, [socket])
@@ -67,7 +63,7 @@ export const DraftAdmin = ({ draftSessionId }) => {
const draftStatusMessageHandler = (event) => handleDraftStatusMessages(event, setDraftState)
const userIdentifyMessageHandler = (event) => handleUserIdentifyMessages(event, setCurrentUser)
const handleNominationRequest = (event)=> {
const handleNominationRequest = (event) => {
const message = JSON.parse(event.data)
const { type, payload } = message;
if (type == DraftMessage.NOMINATION_SUBMIT_REQUEST) {
@@ -79,15 +75,15 @@ export const DraftAdmin = ({ draftSessionId }) => {
))
}
}
socket.addEventListener('message', draftStatusMessageHandler );
socket.addEventListener('message', userIdentifyMessageHandler );
socket.addEventListener('message', handleNominationRequest );
socket.addEventListener('message', draftStatusMessageHandler);
socket.addEventListener('message', userIdentifyMessageHandler);
socket.addEventListener('message', handleNominationRequest);
return () => {
socket.removeEventListener('message', draftStatusMessageHandler)
socket.removeEventListener('message', userIdentifyMessageHandler );
socket.removeEventListener('message', handleNominationRequest );
socket.removeEventListener('message', userIdentifyMessageHandler);
socket.removeEventListener('message', handleNominationRequest);
};
}, [socket]);
@@ -102,7 +98,6 @@ export const DraftAdmin = ({ draftSessionId }) => {
else if (target == "previous" && originPhaseIndex > 0) {
destination = DraftPhasesOrdered[originPhaseIndex - 1]
}
console.log(destination)
socket.send(
JSON.stringify(
{ type: DraftMessage.PHASE_CHANGE_REQUEST, origin, destination }
@@ -134,36 +129,24 @@ export const DraftAdmin = ({ draftSessionId }) => {
const handleStartBidding = () => {
socket.send(
JSON.stringify(
{type: DraftMessage.BID_START_REQUEST}
{ type: DraftMessage.BID_START_REQUEST }
)
)
}
return (
<div className="container draft-panel admin">
<div className="d-flex justify-content-between border-bottom mb-2 p-1">
<h3>Draft Panel</h3>
<div className="d-flex gap-1">
<WebSocketStatus socket={socket} />
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light">
<div id="draft-admin-bar">
<div>
<button onClick={() => handleRequestDraftSummary()} className="btn btn-small btn-light mx-1">
<i className="bi bi-arrow-clockwise"></i>
</button>
</div>
<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>
<DraftPhaseDisplay draftPhase={draftState.phase} nextPhaseHandler={() => { handlePhaseChange('next') }} prevPhaseHandler={() => { handlePhaseChange('previous') }}></DraftPhaseDisplay>
</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>
);
};

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 { useWebSocket } from "./common/WebSocketContext.jsx";
import { useWebSocket } from "./components/WebSocketContext.jsx";
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 }) => {
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";
export function DraftCountdownClock({ endTime, onFinish }) {
// endTime is in seconds (Unix time)
export function DraftCountdownClock({ draftState }) {
const { bidding_timer_end, onFinish } = draftState;
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(() => {
if (timeLeft <= 0) {
setTimeLeft(getTimeLeft(bidding_timer_end)); // reset timer when bidding_timer_end changes
if (getTimeLeft(bidding_timer_end) <= 0) {
if (onFinish) onFinish();
return;
}
const timer = setInterval(() => {
const t = getTimeLeft(endTime);
const t = getTimeLeft(bidding_timer_end);
setTimeLeft(t);
if (t <= 0 && onFinish) onFinish();
}, 100);
return () => clearInterval(timer);
// eslint-disable-next-line
}, [endTime, onFinish, timeLeft]);
}, [bidding_timer_end, onFinish]);
const minutes = Math.floor(timeLeft / 60);
const secs = timeLeft % 60;
@@ -27,7 +29,7 @@ export function DraftCountdownClock({ endTime, onFinish }) {
return (
<div className="countdown-clock">
<span>
{minutes}:{pad(secs)}
{!isNaN(minutes) && !isNaN(secs) ? `${minutes}:${pad(secs)}` : "0:00"}
</span>
</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)
if (!socket) return;
// Set initial state according to readyState
setIsConnected(socket.readyState === 1);
const handleOpen = () => { console.log('socket open'); setIsConnected(true) };
const handleClose = () => { console.log('socket close'); 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_LEAVE_INFORM: "user.leave.inform",
USER_IDENTIFICATION_INFORM: "user.identification.inform",
USER_STATE_INFORM: "user.state.inform",
PHASE_CHANGE_INFORM: "phase.change.inform",
PHASE_CHANGE_REQUEST: "phase.change.request",
PHASE_CHANGE_CONFIRM: "phase.change.confirm",
STATUS_SYNC_REQUEST: "status.sync.request",
STATUS_SYNC_INFORM: "status.sync.inform",
DRAFT_STATUS_REQUEST: "draft.status.request",
DRAFT_STATUS_INFORM: "draft.status.sync.inform",
DRAFT_INDEX_ADVANCE_REQUEST: "draft.index.advance.request",
DRAFT_INDEX_ADVANCE_CONFIRM: "draft.index.advance.confirm",
ORDER_DETERMINE_REQUEST: "order.determine.request",
ORDER_DETERMINE_CONFIRM: "order.determine.confirm",
BID_START_INFORM: "bid.start.inform",
BID_START_REQUEST: "bid.start.request",
BID_START_REJECT: "bid.start.reject",
BID_PLACE_REQUEST: "bid.place.request",
BID_PLACE_REJECT: "bid.place.reject",
BID_PLACE_CONFIRM: "bid.place.confirm",
BID_UPDATE_INFORM: "bid.update.inform",
BID_END_INFORM: "bid.end.inform",
NOMINATION_SUBMIT_REQUEST: "nomination.submit.request",

View File

@@ -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) {
return fetch(`/api/draft/${draftSessionId}/`)
@@ -37,36 +37,12 @@ export function isEmptyObject(obj) {
export const handleDraftStatusMessages = (event, setDraftState) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
console.log("Message: ", type, event?.data);
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((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) => {
@@ -74,8 +50,16 @@ export const handleUserIdentifyMessages = (event, setUser) => {
const { type, payload } = message;
if (type == DraftMessage.USER_IDENTIFICATION_INFORM) {
console.log("Message: ", type, event.data);
const { user } = payload;
setUser(user);
}
};
export const handleUserStatusMessages = (event, setUserStatus) => {
const message = JSON.parse(event.data);
const { type, payload } = message;
if (type == DraftMessage.USER_STATE_INFORM) {
setUserStatus(payload);
}
};

View File

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

View File

@@ -1,6 +1,9 @@
@use "../../node_modules/bootstrap/scss/bootstrap.scss";
@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 {
// background-color: #582f0e;
@@ -92,61 +95,168 @@
}
}
.participant-list-container,
.movie-pool-container {
max-width: 575.98px;
label {
@extend .fs-3;
}
ol.participant-list {
@extend .list-group-numbered;
}
ol.participant-list,
ul.participant-list {
@extend .list-group;
ol,
ul {
@extend .p-0;
}
ol {
@extend .list-group-numbered;
}
li {
@extend .list-group-item;
@extend .d-flex;
@extend .justify-content-between;
@extend .align-items-center;
span {
@extend .me-auto;
@extend .ps-1;
.team-name {
@extend .flex-grow-1;
@extend .ps-2;
}
.team-movie-list {
li {
@extend .p-0;
}
}
}
.current-user {
@extend .fw-bold;
&::after {
content: " *";
// content: " *";
font-size: 1em; // adjust as needed
}
}
}
.draft-participant {
display: flex;
flex-wrap: wrap; /* allow panels to wrap */
gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */
.panel {
flex: 1 1 350px; /* grow/shrink, base width */
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
.movie-pool-container {
img {
height: 128px;
}
.panel.draft-live {
.draft-live-state-container {
@extend .d-flex;
.countdown-clock {
@extend .fs-1;
@extend .fw-bold;
@extend .col;
@extend .align-content-center;
@extend .text-center;
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;
flex-wrap: wrap; /* allow panels to wrap */
gap: 1rem; /* space between panels */
justify-content: center; /* center the panels horizontally */
section {
max-width: 450px; /* never go beyond this */
min-width: 300px; /* keeps them from getting too small */
flex: 1 1 350px; /* grow/shrink, base width */
}
.panel {
@extend .border;
@extend .shadow-sm;
@extend .rounded-2;
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 .fs-5;
}
}
.pick-description{
@extend .col;
}
.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 .align-content-center;
@extend .text-center;
}
.pick-description {
@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