Add DRF API app and real-time draft management UI
- Created new `api` Django app with serializers, viewsets, and routers to expose draft sessions, participants, and movie data. - Registered `api` app in settings and updated root URL configuration. - Extended WebSocket consumers with `inform.draft_status` / `request.draft_status` to allow fetching current draft state. - Updated `DraftSession` and related models to support reverse lookups for draft picks. - Enhanced draft state manager to include `draft_order` in summaries. - Added React WebSocket context provider, connection status component, and new admin/participant panels with phase and participant tracking. - Updated SCSS for participant lists, phase indicators, and status badges. - Modified Django templates to mount new React roots for admin and participant views. - Updated Webpack dev server config to proxy WebSocket connections.
This commit is contained in:
0
api/__init__.py
Normal file
0
api/__init__.py
Normal file
3
api/admin.py
Normal file
3
api/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
api/apps.py
Normal file
6
api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
0
api/migrations/__init__.py
Normal file
0
api/migrations/__init__.py
Normal file
3
api/models.py
Normal file
3
api/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
59
api/serializers.py
Normal file
59
api/serializers.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
full_name = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ("username", "first_name", "last_name", "email", "full_name")
|
||||||
|
|
||||||
|
def get_full_name(self, obj):
|
||||||
|
return f"{obj.first_name} {obj.last_name}".strip()
|
||||||
|
|
||||||
|
class MovieSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Movie
|
||||||
|
# fields = ("id", "imdb_id", "title", "year", "poster_url")
|
||||||
|
fields = ("id", "title")
|
||||||
|
|
||||||
|
class DraftSessionSettingsSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = DraftSessionSettings
|
||||||
|
fields = ("starting_budget",) # add any others you have
|
||||||
|
|
||||||
|
|
||||||
|
class DraftPickSerializer(serializers.ModelSerializer):
|
||||||
|
user = UserSerializer(read_only=True)
|
||||||
|
movie = MovieSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DraftPick
|
||||||
|
fields = ("id", "movie", "winner", "bid_amount")
|
||||||
|
|
||||||
|
class DraftSessionSerializer(serializers.ModelSerializer):
|
||||||
|
participants = UserSerializer(many=True, read_only=True)
|
||||||
|
movies = MovieSerializer(many=True, read_only=True)
|
||||||
|
settings = DraftSessionSettingsSerializer(read_only=True)
|
||||||
|
draft_picks = DraftPickSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
def hashid(self, obj):
|
||||||
|
return f"{obj.hashid}".strip()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DraftSession
|
||||||
|
# include whatever else you want (phase, season info, hashed_id, etc.)
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"hashid",
|
||||||
|
"season", # will use __str__ unless you customize
|
||||||
|
"participants",
|
||||||
|
"movies",
|
||||||
|
"settings",
|
||||||
|
"draft_picks",
|
||||||
|
# optionally include server time for client clock sync
|
||||||
|
)
|
||||||
3
api/tests.py
Normal file
3
api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
9
api/urls.py
Normal file
9
api/urls.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import UserViewSet, MovieViewSet, DraftSessionViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'users', UserViewSet, basename='user')
|
||||||
|
router.register(r'movies', MovieViewSet, basename='movie')
|
||||||
|
router.register(r'draft', DraftSessionViewSet, basename='draft')
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
60
api/views.py
Normal file
60
api/views.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from rest_framework import viewsets, permissions
|
||||||
|
from rest_framework.exceptions import NotFound
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from boxofficefantasy.models import Movie
|
||||||
|
from draft.models import DraftSession, DraftPick
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
from .serializers import (
|
||||||
|
UserSerializer, MovieSerializer, DraftSessionSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class UserViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = User.objects.all()
|
||||||
|
serializer_class = UserSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
lookup_field = "username"
|
||||||
|
|
||||||
|
|
||||||
|
class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
queryset = Movie.objects.all().order_by('id')
|
||||||
|
serializer_class = MovieSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
class DraftSessionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""
|
||||||
|
GET /api/drafts/<hashed_id>/
|
||||||
|
Returns participants, movies, settings, and picks for a draft session.
|
||||||
|
Access limited to participants or staff.
|
||||||
|
"""
|
||||||
|
serializer_class = DraftSessionSerializer
|
||||||
|
# permission_classes = [permissions.IsAuthenticated, IsParticipantOfDraft]
|
||||||
|
lookup_field = "hashid" # use hashed id instead of pk
|
||||||
|
lookup_url_kwarg = "hid" # url kwarg name matches urls.py
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
hashid = self.kwargs[self.lookup_url_kwarg]
|
||||||
|
pk = DraftSession.decode_id(hashid)
|
||||||
|
if pk is None:
|
||||||
|
raise NotFound("Invalid draft id.")
|
||||||
|
obj = get_object_or_404(self.get_queryset(), pk=pk)
|
||||||
|
# Trigger object-level permissions (participant check happens here)
|
||||||
|
self.check_object_permissions(self.request, obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Optimize queries
|
||||||
|
return (
|
||||||
|
DraftSession.objects
|
||||||
|
.select_related("season", "settings")
|
||||||
|
.prefetch_related(
|
||||||
|
"participants",
|
||||||
|
"movies",
|
||||||
|
Prefetch("draft_picks", queryset=DraftPick.objects.select_related("winner", "movie")),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -31,12 +31,10 @@
|
|||||||
"name": "Start Webpack Dev Server",
|
"name": "Start Webpack Dev Server",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"dev",
|
"dev"
|
||||||
"--config",
|
|
||||||
"${workspaceFolder}/frontend/webpack.config.js"
|
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/frontend",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
@@ -63,8 +61,8 @@
|
|||||||
],
|
],
|
||||||
"compounds": [
|
"compounds": [
|
||||||
{
|
{
|
||||||
"name": "Django + Chrome",
|
"name": "Django + Chrome + Webpack",
|
||||||
"configurations": ["Run Django Server", "Launch Chrome"],
|
"configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"],
|
||||||
"type": "compound"
|
"type": "compound"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -154,23 +152,24 @@
|
|||||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||||
},
|
},
|
||||||
"[django-html]": {
|
"[django-html]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "monosans.djlint",
|
||||||
"editor.quickSuggestions": {
|
|
||||||
"other": true,
|
|
||||||
"comments": true,
|
|
||||||
"strings": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"files.exclude": {
|
"emmet.includeLanguages": {
|
||||||
"**/__pycache__":true,
|
"django-html": "html"
|
||||||
".venv":true
|
|
||||||
},
|
},
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.dj.html": "django-html"
|
"*.dj.html": "django-html"
|
||||||
},
|
},
|
||||||
"html.autoClosingTags": true,
|
"files.exclude": {
|
||||||
"emmet.includeLanguages": {
|
"**/__pycache__":true,
|
||||||
"django-html": "html"
|
".venv":false
|
||||||
}
|
},
|
||||||
|
"auto-close-tag.activationOnLanguage": [
|
||||||
|
"django-html"
|
||||||
|
],
|
||||||
|
"terminal.integrated.env.osx": {
|
||||||
|
"VSCODE_HISTFILE":"${workspaceFolder}/.venv/.term_history"
|
||||||
|
},
|
||||||
|
// "html.autoClosingTags": true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
|
|
||||||
{% for draft in draft_sessions %}
|
{% for draft in draft_sessions %}
|
||||||
<li><a href="{% url "draft:session" draft.hashed_id %}">{{ draft.hashed_id }}</a></li>
|
<li><a href="{% url "draft:session" draft.hashid %}">{{ draft.hashid }}</a></li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-_rrxhe5i6uqap!52u(1zi8x$820duvf5s_!9!bc4ghbyyktol0'
|
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
|
||||||
@@ -34,58 +34,60 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
|
|||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
"rest_framework",
|
||||||
"daphne",
|
"daphne",
|
||||||
'boxofficefantasy',
|
"boxofficefantasy",
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'django.contrib.humanize',
|
"django.contrib.humanize",
|
||||||
"draft",
|
"draft",
|
||||||
"channels"
|
"channels",
|
||||||
|
"api"
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'boxofficefantasy_project.urls'
|
ROOT_URLCONF = "boxofficefantasy_project.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [],
|
"DIRS": [],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
'boxofficefantasy.context_processors.debug_flag'
|
"boxofficefantasy.context_processors.debug_flag",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'boxofficefantasy_project.wsgi.application'
|
WSGI_APPLICATION = "boxofficefantasy_project.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,16 +97,16 @@ DATABASES = {
|
|||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -112,9 +114,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -124,7 +126,7 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = "static/"
|
||||||
|
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = BASE_DIR / "media"
|
||||||
@@ -132,7 +134,7 @@ MEDIA_ROOT = BASE_DIR / "media"
|
|||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
|||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path("", include("boxofficefantasy.urls")),
|
path("", include("boxofficefantasy.urls")),
|
||||||
path("draft/", include("draft.urls")),
|
path("draft/", include("draft.urls")),
|
||||||
|
path("api/", include("api.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ class DraftMessage:
|
|||||||
INFORM_PHASE_CHANGE = "inform.phase.change"
|
INFORM_PHASE_CHANGE = "inform.phase.change"
|
||||||
CONFIRM_PHASE_CHANGE = "confirm.phase.change"
|
CONFIRM_PHASE_CHANGE = "confirm.phase.change"
|
||||||
INFORM_PHASE = "inform.phase"
|
INFORM_PHASE = "inform.phase"
|
||||||
|
INFORM_DRAFT_STATUS = "inform.draft_status"
|
||||||
|
|
||||||
# Client
|
# Client
|
||||||
REQUEST_PHASE_CHANGE = "request.phase.change"
|
REQUEST_PHASE_CHANGE = "request.phase.change"
|
||||||
REQUEST_INFORM_STATUS = "request.inform.status"
|
REQUEST_DRAFT_STATUS = "request.draft_status"
|
||||||
|
|
||||||
# Waiting Phase
|
# Waiting Phase
|
||||||
## Server
|
## Server
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from draft.constants import (
|
|||||||
DraftGroupChannelNames,
|
DraftGroupChannelNames,
|
||||||
)
|
)
|
||||||
from draft.state import DraftCacheKeys, DraftStateManager
|
from draft.state import DraftCacheKeys, DraftStateManager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
@@ -39,16 +40,18 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
|||||||
|
|
||||||
self.user = self.scope["user"]
|
self.user = self.scope["user"]
|
||||||
if not self.should_accept_user():
|
if not self.should_accept_user():
|
||||||
await self.send_json({
|
await self.send_json(
|
||||||
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
{
|
||||||
"user": self.user.username
|
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
||||||
})
|
"user": self.user.username,
|
||||||
|
}
|
||||||
|
)
|
||||||
await self.close()
|
await self.close()
|
||||||
await self.channel_layer.group_send(
|
await self.channel_layer.group_send(
|
||||||
self.group_names.session,
|
self.group_names.session,
|
||||||
{
|
{
|
||||||
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
||||||
"user": self.user.username
|
"user": self.user.username,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -59,32 +62,38 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
|||||||
)
|
)
|
||||||
await self.channel_layer.group_send(
|
await self.channel_layer.group_send(
|
||||||
self.group_names.session,
|
self.group_names.session,
|
||||||
|
{"type": DraftMessage.INFORM_JOIN_USER, "user": self.user.username},
|
||||||
|
)
|
||||||
|
await self.send_json(
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": DraftMessage.INFORM_JOIN_USER,
|
"type": DraftMessage.INFORM_DRAFT_STATUS,
|
||||||
"user": self.user.username
|
"payload": self.get_draft_status(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self.channel_layer.group_send(
|
|
||||||
self.group_names.session,
|
async def should_accept_user(self) -> bool:
|
||||||
{
|
|
||||||
"type": DraftMessage.INFORM_PHASE,
|
|
||||||
"phase": str(self.draft_state.phase)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
||||||
event_type = content.get("type")
|
event_type = content.get("type")
|
||||||
|
if event_type == DraftMessage.REQUEST_DRAFT_STATUS:
|
||||||
|
await self.send_json(
|
||||||
|
{
|
||||||
|
"type": DraftMessage.INFORM_DRAFT_STATUS,
|
||||||
|
"payload": self.get_draft_status(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def inform_leave_participant(self,event):
|
async def inform_leave_participant(self, event):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"type": event["type"],
|
"type": event["type"],
|
||||||
"user": event["user"],
|
"user": event["user"],
|
||||||
"participants": [user.username for user in self.draft_participants],
|
"payload": {
|
||||||
"connected_participants": self.draft_state.connected_users
|
"participants": [user.username for user in self.draft_participants],
|
||||||
|
"connected_participants": self.draft_state.connected_users,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,44 +101,51 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
|||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"type": event["type"],
|
"type": event["type"],
|
||||||
"user": event["user"],
|
"payload": {
|
||||||
"participants": [user.username for user in self.draft_participants],
|
"user": event["user"],
|
||||||
"connected_participants": self.draft_state.connected_users
|
"participants": [user.username for user in self.draft_participants],
|
||||||
|
"connected_participants": self.draft_state.connected_users,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def reject_join_participant(self,event):
|
async def inform_draft_status(self, event):
|
||||||
|
await self.send_json(
|
||||||
|
{"type": event["type"], "payload": self.get_draft_status()}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reject_join_participant(self, event):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{
|
{
|
||||||
"type": event["type"],
|
"type": event["type"],
|
||||||
"user": event["user"],
|
"user": event["user"],
|
||||||
"participants": [user.username for user in self.draft_participants],
|
|
||||||
"connected_participants": self.draft_state.connected_users
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
async def inform_phase(self, event):
|
async def inform_phase(self, event):
|
||||||
await self.send_json(
|
await self.send_json({"type": event["type"], "phase": event["phase"]})
|
||||||
{
|
|
||||||
"type": event['type'],
|
|
||||||
"phase": event['phase']
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async def confirm_determine_draft_order(self, event):
|
async def confirm_determine_draft_order(self, event):
|
||||||
await self.send_json(
|
await self.send_json(
|
||||||
{"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER, "payload": event["payload"]}
|
{
|
||||||
|
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
|
||||||
|
"payload": event["payload"],
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def confirm_phase_change(self, event):
|
||||||
|
await self.send_json({"type": event["type"], "payload": event["payload"]})
|
||||||
|
|
||||||
async def send_draft_summary(self): ...
|
async def send_draft_summary(self): ...
|
||||||
|
|
||||||
|
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],
|
||||||
|
}
|
||||||
|
|
||||||
# === Broadcast handlers ===
|
# === Broadcast handlers ===
|
||||||
async def draft_status(self, event):
|
|
||||||
await self.send_json(
|
|
||||||
{
|
|
||||||
"type": "draft.status",
|
|
||||||
"status": event["status"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# === DB Access ===
|
# === DB Access ===
|
||||||
@database_sync_to_async
|
@database_sync_to_async
|
||||||
@@ -162,20 +178,36 @@ class DraftAdminConsumer(DraftConsumerBase):
|
|||||||
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")
|
||||||
user = self.scope["user"]
|
|
||||||
destination = DraftPhase(content.get("destination"))
|
|
||||||
if (
|
if (
|
||||||
event_type == DraftMessage.REQUEST_PHASE_CHANGE
|
event_type == DraftMessage.REQUEST_PHASE_CHANGE
|
||||||
and destination == DraftPhase.DETERMINE_ORDER
|
and content.get("destination") == DraftPhase.DETERMINE_ORDER
|
||||||
):
|
):
|
||||||
await self.determine_draft_order()
|
await self.determine_draft_order()
|
||||||
|
|
||||||
|
if (
|
||||||
|
event_type == DraftMessage.REQUEST_PHASE_CHANGE
|
||||||
|
and content.get("destination") == DraftPhase.NOMINATION
|
||||||
|
):
|
||||||
|
await self.start_nominate();
|
||||||
|
|
||||||
def should_accept_user(self):
|
def should_accept_user(self):
|
||||||
return super().should_accept_user() and self.user.is_staff
|
return super().should_accept_user() and self.user.is_staff
|
||||||
|
|
||||||
# === Draft logic ===
|
# === Draft logic ===
|
||||||
|
async def start_nominate(self):
|
||||||
|
await self.set_draft_phase(DraftPhase.NOMINATION)
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.group_names.session,
|
||||||
|
{
|
||||||
|
"type": DraftMessage.CONFIRM_PHASE_CHANGE,
|
||||||
|
"payload": {"phase": self.draft_state.phase},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
async def determine_draft_order(self):
|
async def determine_draft_order(self):
|
||||||
draft_order = random.sample(self.draft_participants, len(self.draft_participants))
|
draft_order = random.sample(
|
||||||
|
self.draft_participants, len(self.draft_participants)
|
||||||
|
)
|
||||||
self.draft_state.draft_order = [p.username for p in draft_order]
|
self.draft_state.draft_order = [p.username for p in draft_order]
|
||||||
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
|
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
|
||||||
|
|
||||||
@@ -183,32 +215,22 @@ class DraftAdminConsumer(DraftConsumerBase):
|
|||||||
self.group_names.session,
|
self.group_names.session,
|
||||||
{
|
{
|
||||||
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
|
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
|
||||||
"payload": {
|
"payload": {"draft_order": self.draft_state.draft_order},
|
||||||
"draft_order": self.draft_state.draft_order
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
await self.channel_layer.group_send(
|
await self.channel_layer.group_send(
|
||||||
self.group_names.session,
|
self.group_names.session,
|
||||||
{
|
{
|
||||||
"type": DraftMessage.CONFIRM_PHASE_CHANGE,
|
"type": DraftMessage.CONFIRM_PHASE_CHANGE,
|
||||||
"payload": {
|
"payload": {"phase": self.draft_state.phase},
|
||||||
"phase": self.draft_state.phase
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# === Broadcast Handlers ===
|
# === Broadcast Handlers ===
|
||||||
|
|
||||||
async def confirm_phase_change(self, event):
|
|
||||||
await self.send_json({
|
|
||||||
"type": event["type"],
|
|
||||||
"payload": event["payload"]
|
|
||||||
})
|
|
||||||
|
|
||||||
class DraftParticipantConsumer(DraftConsumerBase):
|
class DraftParticipantConsumer(DraftConsumerBase):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
@@ -222,12 +244,9 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
|||||||
|
|
||||||
async def disconnect(self, close_code):
|
async def disconnect(self, close_code):
|
||||||
await self.channel_layer.group_send(
|
await self.channel_layer.group_send(
|
||||||
self.group_names.session,
|
self.group_names.session,
|
||||||
{
|
{"type": DraftMessage.INFORM_LEAVE_PARTICIPANT, "user": self.user.username},
|
||||||
"type": DraftMessage.INFORM_LEAVE_PARTICIPANT,
|
)
|
||||||
"user": self.user.username
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await super().disconnect(close_code)
|
await super().disconnect(close_code)
|
||||||
self.draft_state.disconnect_user(self.user.username)
|
self.draft_state.disconnect_user(self.user.username)
|
||||||
await self.channel_layer.group_discard(
|
await self.channel_layer.group_discard(
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class DraftSessionParticipant(Model):
|
|||||||
|
|
||||||
|
|
||||||
class DraftPick(Model):
|
class DraftPick(Model):
|
||||||
draft = ForeignKey(DraftSession, on_delete=CASCADE)
|
draft = ForeignKey(DraftSession, on_delete=CASCADE, related_name="draft_picks")
|
||||||
movie = ForeignKey(Movie, on_delete=CASCADE)
|
movie = ForeignKey(Movie, on_delete=CASCADE)
|
||||||
winner = ForeignKey(User, on_delete=CASCADE)
|
winner = ForeignKey(User, on_delete=CASCADE)
|
||||||
bid_amount = IntegerField()
|
bid_amount = IntegerField()
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ class DraftStateManager:
|
|||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.keys = DraftCacheKeys(session_id)
|
self.keys = DraftCacheKeys(session_id)
|
||||||
self._phase = self.cache.get(self.keys.phase, DraftPhase.WAITING)
|
self._phase = self.cache.get(self.keys.phase, DraftPhase.WAITING)
|
||||||
self.draft_order = self.cache.get(self.keys.draft_order)
|
|
||||||
|
|
||||||
|
|
||||||
# === Phase Management ===
|
# === Phase Management ===
|
||||||
@@ -98,7 +97,9 @@ class DraftStateManager:
|
|||||||
return json.loads(self.cache.get(self.keys.draft_order,"[]"))
|
return json.loads(self.cache.get(self.keys.draft_order,"[]"))
|
||||||
|
|
||||||
@draft_order.setter
|
@draft_order.setter
|
||||||
def draft_order(self, draft_order: list[User]):
|
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.set(self.keys.draft_order,json.dumps(draft_order))
|
||||||
|
|
||||||
# === Current Nomination / Bid ===
|
# === Current Nomination / Bid ===
|
||||||
@@ -130,8 +131,9 @@ class DraftStateManager:
|
|||||||
def get_summary(self) -> dict:
|
def get_summary(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"phase": self.phase,
|
"phase": self.phase,
|
||||||
|
"draft_order": self.draft_order,
|
||||||
"connected_users": self.connected_users,
|
"connected_users": self.connected_users,
|
||||||
"current_movie": self.cache.get(self.keys.current_movie),
|
# "current_movie": self.cache.get(self.keys.current_movie),
|
||||||
"bids": self.get_bids(),
|
# "bids": self.get_bids(),
|
||||||
"timer_end": self.get_timer_end(),
|
# "timer_end": self.get_timer_end(),
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div id="draft-app" data-draft-id="{{draft_id_hashed}}"></div>
|
<script>
|
||||||
|
window.draftSessionId = "{{ draft_id_hashed }}"
|
||||||
|
</script>
|
||||||
|
<div id="draft-participant-root" data-draft-id="{{ draft_id_hashed }}"></div>
|
||||||
{% if DEBUG %}
|
{% if DEBUG %}
|
||||||
<script src="http://localhost:3000/dist/bundle.js"></script>
|
<script src="http://localhost:3000/dist/bundle.js"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<script src="{% static 'bundle.js' %}"></script>
|
<script src="{% static 'bundle.js' %}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock content %}
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
||||||
{% load static %}
|
{% load static %}
|
||||||
<div id="draft-admin-app" data-draft-id="{{ draft_id_hashed }}"></div>
|
<script>
|
||||||
|
window.draftSessionId = "{{ draft_id_hashed }}"
|
||||||
|
</script>
|
||||||
|
<div id="draft-admin-root" data-draft-hid="{{ draft_id_hashed }}"></div>
|
||||||
{% if DEBUG %}
|
{% if DEBUG %}
|
||||||
<script src="http://localhost:3000/dist/bundle.js"></script>
|
<script src="http://localhost:3000/dist/bundle.js"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
16
frontend/src/apps/draft/WebSocketContext.jsx
Normal file
16
frontend/src/apps/draft/WebSocketContext.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// WebSocketContext.jsx
|
||||||
|
import React, { useState, createContext, useContext, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
const WebSocketContext = createContext(null);
|
||||||
|
|
||||||
|
export const WebSocketProvider = ({ url, children }) => {
|
||||||
|
const [socket] = useState(() => new WebSocket(url));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider value={socket}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWebSocket = () => useContext(WebSocketContext);
|
||||||
198
frontend/src/apps/draft/admin/DraftAdmin.jsx
Normal file
198
frontend/src/apps/draft/admin/DraftAdmin.jsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// DraftAdmin.jsx
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useWebSocket } from "../WebSocketContext.jsx";
|
||||||
|
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
|
||||||
|
import { DraftMessage, DraftPhases, DraftPhase } from '../constants.js';
|
||||||
|
|
||||||
|
const ParticipantList = ({ socket, participants, draftOrder }) => {
|
||||||
|
const [connectedParticipants, setConnectedParticipants] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleMessage = async ({ data }) => {
|
||||||
|
const message = JSON.parse(data)
|
||||||
|
const { type, payload } = message
|
||||||
|
console.log('socket changed', message)
|
||||||
|
if (payload?.connected_participants) {
|
||||||
|
const { connected_participants } = payload
|
||||||
|
setConnectedParticipants(connected_participants)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.addEventListener("message", handleMessage)
|
||||||
|
return () => {
|
||||||
|
socket.removeEventListener("message", handleMessage)
|
||||||
|
}
|
||||||
|
}, [socket])
|
||||||
|
|
||||||
|
const ListTag = draftOrder.length > 0 ? "ol" : "ul"
|
||||||
|
console.log
|
||||||
|
const listItems = draftOrder.length > 0 ? draftOrder.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}>
|
||||||
|
<span>{p?.full_name}</span>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
`ms-2 stop-light ${connectedParticipants.includes(p?.username) ? "success" : "danger"}`
|
||||||
|
}
|
||||||
|
></div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ListTag>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraftPhaseDisplay = ({ draftPhase }) => {
|
||||||
|
return (
|
||||||
|
<div className="draft-phase-container">
|
||||||
|
<label>Phase</label>
|
||||||
|
<ol>
|
||||||
|
{
|
||||||
|
DraftPhases.map((p) => (
|
||||||
|
<li key={p} className={p === draftPhase ? "current-phase" : ""}>
|
||||||
|
<span>{p}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DraftOrder = ({ socket, draftOrder }) => {
|
||||||
|
console.log("in component", draftOrder)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>Draft Order</label>
|
||||||
|
<ol>
|
||||||
|
{
|
||||||
|
draftOrder.map((p) => (
|
||||||
|
<li key={p}>
|
||||||
|
{p}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftAdmin = ({ draftSessionId }) => {
|
||||||
|
const socket = useWebSocket();
|
||||||
|
const [connectedParticipants, setConnectedParticipants] = useState([]);
|
||||||
|
const [draftDetails, setDraftDetails] = useState();
|
||||||
|
const [participants, setParticipants] = React.useState([]);
|
||||||
|
const [draftPhase, setDraftPhase] = useState();
|
||||||
|
const [draftOrder, setDraftOrder] = useState([]);
|
||||||
|
console.log(socket)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDraftDetails(draftSessionId) {
|
||||||
|
fetch(`/api/draft/${draftSessionId}/`)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
console.log(data)
|
||||||
|
setParticipants(data.participants)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Error fetching draft details", err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fetchDraftDetails(draftSessionId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
|
||||||
|
if (!socket) return;
|
||||||
|
else {
|
||||||
|
console.warn("socket doesn't exist")
|
||||||
|
}
|
||||||
|
console.log('socket created', socket)
|
||||||
|
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
const { type, payload } = message;
|
||||||
|
console.log(type, event)
|
||||||
|
if (!payload) return
|
||||||
|
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
|
||||||
|
console.log('join request', data)
|
||||||
|
}
|
||||||
|
if (payload.phase) {
|
||||||
|
console.log('phase_change')
|
||||||
|
setDraftPhase(payload.phase)
|
||||||
|
}
|
||||||
|
if (payload.draft_order) {
|
||||||
|
console.log('draft_order', payload.draft_order)
|
||||||
|
setDraftOrder(payload.draft_order)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
console.log('Websocket Closed')
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.removeEventListener('message', handleMessage)
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const handlePhaseChange = (destinationPhase) => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify(
|
||||||
|
{ type: DraftMessage.REQUEST.PHASE_CHANGE, "origin": draftPhase, "destination": destinationPhase }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleRequestDraftSummary = () => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify(
|
||||||
|
{ type: DraftMessage.REQUEST.DRAFT_STATUS }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container draft-panel admin">
|
||||||
|
<h3>Draft Admin Panel</h3>
|
||||||
|
<WebSocketStatus socket={socket} />
|
||||||
|
{/* <MessageLogger socket={socketRef.current} /> */}
|
||||||
|
|
||||||
|
<ParticipantList
|
||||||
|
socket={socket}
|
||||||
|
participants={participants}
|
||||||
|
draftOrder={draftOrder}
|
||||||
|
/>
|
||||||
|
<DraftPhaseDisplay draftPhase={draftPhase}></DraftPhaseDisplay>
|
||||||
|
<button onClick={() => handlePhaseChange(DraftPhase.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2">
|
||||||
|
Determine Draft Order
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleRequestDraftSummary()} className="btn btn-primary mt-2">
|
||||||
|
Request status
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handlePhaseChange(DraftPhase.NOMINATION)} className="btn btn-primary mt-2 me-2">
|
||||||
|
Go to Nominate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
frontend/src/apps/draft/common/WebSocketStatus.jsx
Normal file
34
frontend/src/apps/draft/common/WebSocketStatus.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
export const WebSocketStatus = ({ socket }) => {
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('socket changed', socket)
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleOpen = () => { console.log('socket open'); setIsConnected(true) };
|
||||||
|
const handleClose = () => { console.log('socket close'); setIsConnected(false) };
|
||||||
|
const handleError = () => { console.log('socket error'); setIsConnected(false) };
|
||||||
|
|
||||||
|
socket.addEventListener("open", handleOpen);
|
||||||
|
socket.addEventListener("close", handleClose);
|
||||||
|
socket.addEventListener("error", handleError);
|
||||||
|
|
||||||
|
// 🧹 Cleanup to remove listeners when component unmounts or socket changes
|
||||||
|
return () => {
|
||||||
|
socket.removeEventListener("open", handleOpen);
|
||||||
|
socket.removeEventListener("close", handleClose);
|
||||||
|
socket.removeEventListener("error", handleError);
|
||||||
|
};
|
||||||
|
|
||||||
|
}, [socket])
|
||||||
|
return (
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`badge ${isConnected ? "text-bg-success" : "text-bg-danger"}`}
|
||||||
|
>
|
||||||
|
{isConnected ? "Connected" : "Disconnected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,8 +2,10 @@ export const DraftMessage = {
|
|||||||
// Server to Client
|
// Server to Client
|
||||||
INFORM: {
|
INFORM: {
|
||||||
PHASE_CHANGE: "inform.phase.change",
|
PHASE_CHANGE: "inform.phase.change",
|
||||||
|
PHASE: "inform.phase",
|
||||||
STATUS: "inform.status",
|
STATUS: "inform.status",
|
||||||
JOIN_USER: "inform.join.user",
|
JOIN_USER: "inform.join.user",
|
||||||
|
DRAFT_STATUS: "inform.draft_status"
|
||||||
},
|
},
|
||||||
|
|
||||||
// Client to Server
|
// Client to Server
|
||||||
@@ -13,6 +15,7 @@ export const DraftMessage = {
|
|||||||
JOIN_PARTICIPANT: "request.join.participant",
|
JOIN_PARTICIPANT: "request.join.participant",
|
||||||
JOIN_ADMIN: "request.join.admin",
|
JOIN_ADMIN: "request.join.admin",
|
||||||
DETERMINE_DRAFT_ORDER: "request.determine.draft_order",
|
DETERMINE_DRAFT_ORDER: "request.determine.draft_order",
|
||||||
|
DRAFT_STATUS: "request.draft_status"
|
||||||
},
|
},
|
||||||
|
|
||||||
// Confirmation messages (Server to Client)
|
// Confirmation messages (Server to Client)
|
||||||
@@ -36,4 +39,13 @@ export const DraftPhase = {
|
|||||||
BIDDING: 30,
|
BIDDING: 30,
|
||||||
AWARD: 40,
|
AWARD: 40,
|
||||||
FINALIZE: 50,
|
FINALIZE: 50,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DraftPhases = [
|
||||||
|
"waiting",
|
||||||
|
"determine_order",
|
||||||
|
"nomination",
|
||||||
|
"bidding",
|
||||||
|
"award",
|
||||||
|
"finalize",
|
||||||
|
]
|
||||||
@@ -1,21 +1,61 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { createContext, useContext, useEffect, useState, useRef } from "react";
|
||||||
import { DraftMessage, DraftPhase } from './constants.js';
|
import { DraftMessage, DraftPhases } from './constants.js';
|
||||||
|
|
||||||
|
|
||||||
|
const WebSocketContext = createContext(null);
|
||||||
|
|
||||||
|
export const WebSocketProvider = ({ url, children }) => {
|
||||||
|
const socketRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socketRef.current) {
|
||||||
|
socketRef.current = new WebSocket(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socketRef.current?.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebSocketContext.Provider value={socketRef.current}>
|
||||||
|
{children}
|
||||||
|
</WebSocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useWebSocket = () => {
|
||||||
|
return useContext(WebSocketContext);
|
||||||
|
};
|
||||||
|
|
||||||
export const WebSocketStatus = ({ socket }) => {
|
export const WebSocketStatus = ({ socket }) => {
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log('socket changed', socket)
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
if (!socket) return;
|
const handleOpen = () => {console.log('socket open'); setIsConnected(true)};
|
||||||
|
const handleClose = () => setIsConnected(false);
|
||||||
|
const handleError = () => setIsConnected(false);
|
||||||
|
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
setIsConnected(true);
|
console.log('socket already connected')
|
||||||
}
|
setIsConnected(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener("open", handleOpen);
|
||||||
|
socket.addEventListener("close", handleClose);
|
||||||
|
socket.addEventListener("error", handleError);
|
||||||
|
|
||||||
|
// 🧹 Cleanup to remove listeners when component unmounts or socket changes
|
||||||
|
return () => {
|
||||||
|
socket.removeEventListener("open", handleOpen);
|
||||||
|
socket.removeEventListener("close", handleClose);
|
||||||
|
socket.removeEventListener("error", handleError);
|
||||||
|
};
|
||||||
|
|
||||||
socket.addEventListener("open", () => setIsConnected(true));
|
|
||||||
socket.addEventListener("close", () => setIsConnected(false));
|
|
||||||
socket.addEventListener("error", () => setIsConnected(false));
|
|
||||||
}, [socket])
|
}, [socket])
|
||||||
return (
|
return (
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
@@ -49,6 +89,7 @@ export const MessageLogger = ({ socket }) => {
|
|||||||
socket.addEventListener("message", handleMessage);
|
socket.addEventListener("message", handleMessage);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('removing event listeners')
|
||||||
socket.removeEventListener("message", handleMessage);
|
socket.removeEventListener("message", handleMessage);
|
||||||
};
|
};
|
||||||
}, [socket]);
|
}, [socket]);
|
||||||
@@ -56,7 +97,7 @@ export const MessageLogger = ({ socket }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Scroll to bottom when messages update
|
// Scroll to bottom when messages update
|
||||||
if (bottomRef.current) {
|
if (bottomRef.current) {
|
||||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
bottomRef.current.scrollIntoView({ behavior: "smooth" , block: 'nearest', inline: 'start'});
|
||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
@@ -77,21 +118,21 @@ export const MessageLogger = ({ socket }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DraftAdmin = ({ draftSessionId }) => {
|
export const DraftAdmin = ({ draftSessionId }) => {
|
||||||
const [latestMessage, setLatestMessage] = useState(null);
|
|
||||||
const [connectedParticipants, setConnectedParticipants] = useState([]);
|
const [connectedParticipants, setConnectedParticipants] = useState([]);
|
||||||
const [draftPhase, setDraftPhase] = useState();
|
const [draftPhase, setDraftPhase] = useState();
|
||||||
|
|
||||||
const socketRef = useRef(null);
|
const socketRef = useWebSocket();
|
||||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (socketRef.current) return;
|
||||||
|
console.log('socket created')
|
||||||
socketRef.current = new WebSocket(wsUrl);
|
socketRef.current = new WebSocket(wsUrl);
|
||||||
|
|
||||||
socketRef.current.onmessage = (event) => {
|
socketRef.current.onmessage = (event) => {
|
||||||
const message = JSON.parse(event.data)
|
const message = JSON.parse(event.data)
|
||||||
const { type, payload } = message;
|
const { type, payload } = message;
|
||||||
console.log(type, event)
|
console.log(type, event)
|
||||||
setLatestMessage(message);
|
|
||||||
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
|
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
|
||||||
console.log('join request', data)
|
console.log('join request', data)
|
||||||
}
|
}
|
||||||
@@ -124,10 +165,11 @@ export const DraftAdmin = ({ draftSessionId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<WebSocketContext url={wsUrl}>
|
||||||
<div className="container draft-panel">
|
<div className="container draft-panel">
|
||||||
<h3>Draft Admin Panel</h3>
|
<h3>Draft Admin Panel</h3>
|
||||||
<WebSocketStatus socket={socketRef.current} />
|
<WebSocketStatus socket={socket} />
|
||||||
<MessageLogger socket={socketRef.current}></MessageLogger>
|
{/* <MessageLogger socket={socketRef.current} /> */}
|
||||||
<label>Connected Particpants</label>
|
<label>Connected Particpants</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -147,11 +189,11 @@ export const DraftAdmin = ({ draftSessionId }) => {
|
|||||||
Request status
|
Request status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</WebSocketContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DraftParticipant = ({ draftSessionId }) => {
|
export const DraftParticipant = ({ draftSessionId }) => {
|
||||||
const [latestMessage, setLatestMessage] = useState(null);
|
|
||||||
const socketRef = useRef(null);
|
const socketRef = useRef(null);
|
||||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
|
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
|
||||||
|
|
||||||
@@ -161,7 +203,6 @@ export const DraftParticipant = ({ draftSessionId }) => {
|
|||||||
socketRef.current.onmessage = (evt) => {
|
socketRef.current.onmessage = (evt) => {
|
||||||
const data = JSON.parse(evt.data);
|
const data = JSON.parse(evt.data);
|
||||||
console.log(data)
|
console.log(data)
|
||||||
setLatestMessage(data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
socketRef.current.onclose = () => {
|
socketRef.current.onclose = () => {
|
||||||
@@ -183,11 +224,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
|
|||||||
<h3 >Draft Participant Panel</h3>
|
<h3 >Draft Participant Panel</h3>
|
||||||
<WebSocketStatus socket={socketRef.current} />
|
<WebSocketStatus socket={socketRef.current} />
|
||||||
<label>Latest Message</label>
|
<label>Latest Message</label>
|
||||||
<input
|
<MessageLogger socket={socketRef.current}></MessageLogger>
|
||||||
type="text"
|
|
||||||
readOnly disabled
|
|
||||||
value={latestMessage ? JSON.stringify(latestMessage) : ""}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
86
frontend/src/apps/draft/participant/DraftParticipant.jsx
Normal file
86
frontend/src/apps/draft/participant/DraftParticipant.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// DraftAdmin.jsx
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useWebSocket } from "../WebSocketContext.jsx";
|
||||||
|
import { WebSocketStatus } from "../common/WebSocketStatus.jsx";
|
||||||
|
import { DraftMessage, DraftPhases } from '../constants.js';
|
||||||
|
|
||||||
|
export const DraftParticipant = ({ draftSessionId }) => {
|
||||||
|
const socket = useWebSocket();
|
||||||
|
const [connectedParticipants, setConnectedParticipants] = useState([]);
|
||||||
|
const [draftPhase, setDraftPhase] = useState();
|
||||||
|
console.log(socket)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
else {
|
||||||
|
console.warn("socket doesn't exist")
|
||||||
|
}
|
||||||
|
console.log('socket created', socket)
|
||||||
|
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
const message = JSON.parse(event.data)
|
||||||
|
const { type, payload } = message;
|
||||||
|
console.log(type, event)
|
||||||
|
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
|
||||||
|
console.log('join request', data)
|
||||||
|
}
|
||||||
|
else if (type == DraftMessage.CONFIRM.JOIN_PARTICIPANT) {
|
||||||
|
setConnectedParticipants(data.connected_participants)
|
||||||
|
}
|
||||||
|
else if (type == DraftMessage.CONFIRM.PHASE_CHANGE || type == DraftMessage.INFORM.PHASE) {
|
||||||
|
console.log('phase_change')
|
||||||
|
setDraftPhase(payload.phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
console.log('Websocket Closed')
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.removeEventListener('message', handleMessage)
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const handlePhaseChange = (destinationPhase) => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({ type: DraftMessage.REQUEST.PHASE_CHANGE, "destination": destinationPhase })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleRequestDraftSummary = () => {
|
||||||
|
socket.send(JSON.stringify({ type: 'request_summary' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container draft-panel">
|
||||||
|
<h3>Draft Admin Panel</h3>
|
||||||
|
<WebSocketStatus socket={socket} />
|
||||||
|
{/* <MessageLogger socket={socketRef.current} /> */}
|
||||||
|
<label>Connected Particpants</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly disabled
|
||||||
|
value={connectedParticipants ? JSON.stringify(connectedParticipants) : ""}
|
||||||
|
/>
|
||||||
|
<label>Draft Phase</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly disabled
|
||||||
|
value={draftPhase ? draftPhase : ""}
|
||||||
|
/>
|
||||||
|
<button onClick={() => handlePhaseChange(DraftPhases.DETERMINE_ORDER)} className="btn btn-primary mt-2 me-2">
|
||||||
|
Determine Draft Order
|
||||||
|
</button>
|
||||||
|
<button onClick={handleRequestDraftSummary} className="btn btn-primary mt-2">
|
||||||
|
Request status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
import './scss/styles.scss'
|
import './scss/styles.scss'
|
||||||
console.log("Webpack HMR loaded!");
|
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import {DraftAdmin, DraftParticipant} from './apps/draft/index.jsx'
|
import { WebSocketProvider } from "./apps/draft/WebSocketContext.jsx";
|
||||||
|
import { DraftAdmin } from "./apps/draft/admin/DraftAdmin.jsx";
|
||||||
|
import { DraftParticipant} from './apps/draft/participant/DraftParticipant.jsx'
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
const draftAdminApp = document.getElementById("draft-admin-app");
|
|
||||||
const draftApp = document.getElementById("draft-app")
|
|
||||||
if (draftApp) {
|
|
||||||
const root = createRoot(draftApp);
|
|
||||||
const draftId = draftApp.dataset.draftId
|
|
||||||
root.render(<DraftParticipant draftSessionId={draftId} />);
|
|
||||||
|
|
||||||
}
|
const draftAdminRoot = document.getElementById("draft-admin-root");
|
||||||
if (draftAdminApp) {
|
const draftPartipantRoot = document.getElementById("draft-participant-root")
|
||||||
const root = createRoot(draftAdminApp);
|
const {draftSessionId} = window; // from backend template
|
||||||
const draftId = draftAdminApp.dataset.draftId
|
|
||||||
root.render(<DraftAdmin draftSessionId={draftId} />);
|
if (draftPartipantRoot) {
|
||||||
}
|
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
|
||||||
});
|
createRoot(draftPartipantRoot).render(
|
||||||
|
<WebSocketProvider url={wsUrl}>
|
||||||
|
<DraftParticipant />
|
||||||
|
</WebSocketProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (draftAdminRoot) {
|
||||||
|
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
||||||
|
createRoot(draftAdminRoot).render(
|
||||||
|
<WebSocketProvider url={wsUrl}>
|
||||||
|
<DraftAdmin draftSessionId={draftSessionId}/>
|
||||||
|
</WebSocketProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,3 +37,66 @@
|
|||||||
padding: 1em;
|
padding: 1em;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stop-light {
|
||||||
|
@extend .me-2;
|
||||||
|
// @extend .badge;
|
||||||
|
// @extend .rounded-pill;
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
@extend .bg-success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
@extend .bg-danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-phase-container {
|
||||||
|
label {
|
||||||
|
@extend .fs-3;
|
||||||
|
}
|
||||||
|
ol, ul {
|
||||||
|
@extend .list-group;
|
||||||
|
@extend .list-group-horizontal;
|
||||||
|
li {
|
||||||
|
@extend .list-group-item;
|
||||||
|
@extend .p-1;
|
||||||
|
@extend .ps-2;
|
||||||
|
@extend .pe-2;
|
||||||
|
|
||||||
|
&.current-phase {
|
||||||
|
@extend .active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.participant-list-container {
|
||||||
|
max-width: 575.98px;
|
||||||
|
label {
|
||||||
|
@extend .fs-3;
|
||||||
|
}
|
||||||
|
@extend .list-group;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
&::marker{
|
||||||
|
content:">";
|
||||||
|
color:green;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ module.exports = {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
context: (pathname) => pathname.startsWith('/ws/'),
|
||||||
|
target: 'ws://localhost:8000',
|
||||||
|
ws: true, // <-- enable websocket proxying
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ignoreWarnings: [
|
ignoreWarnings: [
|
||||||
|
|||||||
Reference in New Issue
Block a user