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",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "npm",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev",
|
||||
"--config",
|
||||
"${workspaceFolder}/frontend/webpack.config.js"
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"console": "integratedTerminal",
|
||||
@@ -63,8 +61,8 @@
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Django + Chrome",
|
||||
"configurations": ["Run Django Server", "Launch Chrome"],
|
||||
"name": "Django + Chrome + Webpack",
|
||||
"configurations": ["Run Django Server", "Launch Chrome", "Start Webpack Dev Server"],
|
||||
"type": "compound"
|
||||
}
|
||||
]
|
||||
@@ -154,23 +152,24 @@
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"[django-html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.quickSuggestions": {
|
||||
"other": true,
|
||||
"comments": true,
|
||||
"strings": true
|
||||
}
|
||||
"editor.defaultFormatter": "monosans.djlint",
|
||||
},
|
||||
"files.exclude": {
|
||||
"**/__pycache__":true,
|
||||
".venv":true
|
||||
"emmet.includeLanguages": {
|
||||
"django-html": "html"
|
||||
},
|
||||
"files.associations": {
|
||||
"*.dj.html": "django-html"
|
||||
},
|
||||
"html.autoClosingTags": true,
|
||||
"emmet.includeLanguages": {
|
||||
"django-html": "html"
|
||||
}
|
||||
"files.exclude": {
|
||||
"**/__pycache__":true,
|
||||
".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>
|
||||
|
||||
{% 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
|
||||
# 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!
|
||||
DEBUG = True
|
||||
@@ -34,58 +34,60 @@ TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"rest_framework",
|
||||
"daphne",
|
||||
'boxofficefantasy',
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
"boxofficefantasy",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.humanize",
|
||||
"draft",
|
||||
"channels"
|
||||
"channels",
|
||||
"api"
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'boxofficefantasy_project.urls'
|
||||
ROOT_URLCONF = "boxofficefantasy_project.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'boxofficefantasy.context_processors.debug_flag'
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"boxofficefantasy.context_processors.debug_flag",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'boxofficefantasy_project.wsgi.application'
|
||||
WSGI_APPLICATION = "boxofficefantasy_project.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,16 +97,16 @@ DATABASES = {
|
||||
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -124,7 +126,7 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_URL = "static/"
|
||||
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
@@ -132,7 +134,7 @@ MEDIA_ROOT = BASE_DIR / "media"
|
||||
# Default primary key field type
|
||||
# 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 = [
|
||||
"http://localhost:3000",
|
||||
|
||||
@@ -24,6 +24,7 @@ urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path("", include("boxofficefantasy.urls")),
|
||||
path("draft/", include("draft.urls")),
|
||||
path("api/", include("api.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
@@ -5,10 +5,11 @@ class DraftMessage:
|
||||
INFORM_PHASE_CHANGE = "inform.phase.change"
|
||||
CONFIRM_PHASE_CHANGE = "confirm.phase.change"
|
||||
INFORM_PHASE = "inform.phase"
|
||||
INFORM_DRAFT_STATUS = "inform.draft_status"
|
||||
|
||||
# Client
|
||||
REQUEST_PHASE_CHANGE = "request.phase.change"
|
||||
REQUEST_INFORM_STATUS = "request.inform.status"
|
||||
REQUEST_DRAFT_STATUS = "request.draft_status"
|
||||
|
||||
# Waiting Phase
|
||||
## Server
|
||||
|
||||
@@ -13,6 +13,7 @@ from draft.constants import (
|
||||
DraftGroupChannelNames,
|
||||
)
|
||||
from draft.state import DraftCacheKeys, DraftStateManager
|
||||
from typing import Any
|
||||
|
||||
import random
|
||||
|
||||
@@ -39,16 +40,18 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
|
||||
self.user = self.scope["user"]
|
||||
if not self.should_accept_user():
|
||||
await self.send_json({
|
||||
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
||||
"user": self.user.username
|
||||
})
|
||||
await self.send_json(
|
||||
{
|
||||
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
||||
"user": self.user.username,
|
||||
}
|
||||
)
|
||||
await self.close()
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": DraftMessage.REJECT_JOIN_PARTICIPANT,
|
||||
"user": self.user.username
|
||||
"user": self.user.username,
|
||||
},
|
||||
)
|
||||
return
|
||||
@@ -59,32 +62,38 @@ class DraftConsumerBase(AsyncJsonWebsocketConsumer):
|
||||
)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{"type": DraftMessage.INFORM_JOIN_USER, "user": self.user.username},
|
||||
)
|
||||
await self.send_json(
|
||||
|
||||
{
|
||||
"type": DraftMessage.INFORM_JOIN_USER,
|
||||
"user": self.user.username
|
||||
"type": DraftMessage.INFORM_DRAFT_STATUS,
|
||||
"payload": self.get_draft_status(),
|
||||
},
|
||||
)
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": DraftMessage.INFORM_PHASE,
|
||||
"phase": str(self.draft_state.phase)
|
||||
}
|
||||
)
|
||||
|
||||
async def should_accept_user(self)->bool:
|
||||
|
||||
async def should_accept_user(self) -> bool:
|
||||
return self.user.is_authenticated
|
||||
|
||||
async def receive_json(self, content):
|
||||
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(
|
||||
{
|
||||
"type": event["type"],
|
||||
"user": event["user"],
|
||||
"participants": [user.username for user in self.draft_participants],
|
||||
"connected_participants": self.draft_state.connected_users
|
||||
"payload": {
|
||||
"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(
|
||||
{
|
||||
"type": event["type"],
|
||||
"user": event["user"],
|
||||
"participants": [user.username for user in self.draft_participants],
|
||||
"connected_participants": self.draft_state.connected_users
|
||||
"payload": {
|
||||
"user": event["user"],
|
||||
"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(
|
||||
{
|
||||
"type": event["type"],
|
||||
"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):
|
||||
await self.send_json(
|
||||
{
|
||||
"type": event['type'],
|
||||
"phase": event['phase']
|
||||
}
|
||||
)
|
||||
await self.send_json({"type": event["type"], "phase": event["phase"]})
|
||||
|
||||
async def confirm_determine_draft_order(self, event):
|
||||
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): ...
|
||||
|
||||
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 ===
|
||||
async def draft_status(self, event):
|
||||
await self.send_json(
|
||||
{
|
||||
"type": "draft.status",
|
||||
"status": event["status"],
|
||||
}
|
||||
)
|
||||
|
||||
# === DB Access ===
|
||||
@database_sync_to_async
|
||||
@@ -162,20 +178,36 @@ class DraftAdminConsumer(DraftConsumerBase):
|
||||
async def receive_json(self, content):
|
||||
await super().receive_json(content)
|
||||
event_type = content.get("type")
|
||||
user = self.scope["user"]
|
||||
destination = DraftPhase(content.get("destination"))
|
||||
if (
|
||||
event_type == DraftMessage.REQUEST_PHASE_CHANGE
|
||||
and destination == DraftPhase.DETERMINE_ORDER
|
||||
and content.get("destination") == DraftPhase.DETERMINE_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):
|
||||
return super().should_accept_user() and self.user.is_staff
|
||||
|
||||
# === 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):
|
||||
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]
|
||||
await self.set_draft_phase(DraftPhase.DETERMINE_ORDER)
|
||||
|
||||
@@ -183,32 +215,22 @@ class DraftAdminConsumer(DraftConsumerBase):
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": DraftMessage.CONFIRM_DETERMINE_DRAFT_ORDER,
|
||||
"payload": {
|
||||
"draft_order": self.draft_state.draft_order
|
||||
},
|
||||
"payload": {"draft_order": self.draft_state.draft_order},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def set_draft_phase(self, destination: DraftPhase):
|
||||
self.draft_state.phase = destination
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": DraftMessage.CONFIRM_PHASE_CHANGE,
|
||||
"payload": {
|
||||
"phase": self.draft_state.phase
|
||||
},
|
||||
"payload": {"phase": self.draft_state.phase},
|
||||
},
|
||||
)
|
||||
|
||||
# === Broadcast Handlers ===
|
||||
|
||||
async def confirm_phase_change(self, event):
|
||||
await self.send_json({
|
||||
"type": event["type"],
|
||||
"payload": event["payload"]
|
||||
})
|
||||
|
||||
class DraftParticipantConsumer(DraftConsumerBase):
|
||||
async def connect(self):
|
||||
@@ -222,12 +244,9 @@ class DraftParticipantConsumer(DraftConsumerBase):
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_send(
|
||||
self.group_names.session,
|
||||
{
|
||||
"type": DraftMessage.INFORM_LEAVE_PARTICIPANT,
|
||||
"user": self.user.username
|
||||
},
|
||||
)
|
||||
self.group_names.session,
|
||||
{"type": DraftMessage.INFORM_LEAVE_PARTICIPANT, "user": self.user.username},
|
||||
)
|
||||
await super().disconnect(close_code)
|
||||
self.draft_state.disconnect_user(self.user.username)
|
||||
await self.channel_layer.group_discard(
|
||||
|
||||
@@ -50,7 +50,7 @@ class DraftSessionParticipant(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)
|
||||
winner = ForeignKey(User, on_delete=CASCADE)
|
||||
bid_amount = IntegerField()
|
||||
|
||||
@@ -65,7 +65,6 @@ class DraftStateManager:
|
||||
self.cache = cache
|
||||
self.keys = DraftCacheKeys(session_id)
|
||||
self._phase = self.cache.get(self.keys.phase, DraftPhase.WAITING)
|
||||
self.draft_order = self.cache.get(self.keys.draft_order)
|
||||
|
||||
|
||||
# === Phase Management ===
|
||||
@@ -98,7 +97,9 @@ class DraftStateManager:
|
||||
return json.loads(self.cache.get(self.keys.draft_order,"[]"))
|
||||
|
||||
@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))
|
||||
|
||||
# === Current Nomination / Bid ===
|
||||
@@ -130,8 +131,9 @@ class DraftStateManager:
|
||||
def get_summary(self) -> dict:
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"draft_order": self.draft_order,
|
||||
"connected_users": self.connected_users,
|
||||
"current_movie": self.cache.get(self.keys.current_movie),
|
||||
"bids": self.get_bids(),
|
||||
"timer_end": self.get_timer_end(),
|
||||
# "current_movie": self.cache.get(self.keys.current_movie),
|
||||
# "bids": self.get_bids(),
|
||||
# "timer_end": self.get_timer_end(),
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
{% block content %}
|
||||
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
||||
{% 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 %}
|
||||
<script src="http://localhost:3000/dist/bundle.js"></script>
|
||||
{% else %}
|
||||
<script src="{% static 'bundle.js' %}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endblock content %}
|
||||
@@ -2,7 +2,10 @@
|
||||
{% block content %}
|
||||
<h1>Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}</h1>
|
||||
{% 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 %}
|
||||
<script src="http://localhost:3000/dist/bundle.js"></script>
|
||||
{% 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
|
||||
INFORM: {
|
||||
PHASE_CHANGE: "inform.phase.change",
|
||||
PHASE: "inform.phase",
|
||||
STATUS: "inform.status",
|
||||
JOIN_USER: "inform.join.user",
|
||||
DRAFT_STATUS: "inform.draft_status"
|
||||
},
|
||||
|
||||
// Client to Server
|
||||
@@ -13,6 +15,7 @@ export const DraftMessage = {
|
||||
JOIN_PARTICIPANT: "request.join.participant",
|
||||
JOIN_ADMIN: "request.join.admin",
|
||||
DETERMINE_DRAFT_ORDER: "request.determine.draft_order",
|
||||
DRAFT_STATUS: "request.draft_status"
|
||||
},
|
||||
|
||||
// Confirmation messages (Server to Client)
|
||||
@@ -36,4 +39,13 @@ export const DraftPhase = {
|
||||
BIDDING: 30,
|
||||
AWARD: 40,
|
||||
FINALIZE: 50,
|
||||
}
|
||||
}
|
||||
|
||||
export const DraftPhases = [
|
||||
"waiting",
|
||||
"determine_order",
|
||||
"nomination",
|
||||
"bidding",
|
||||
"award",
|
||||
"finalize",
|
||||
]
|
||||
@@ -1,21 +1,61 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { DraftMessage, DraftPhase } from './constants.js';
|
||||
import React, { createContext, useContext, useEffect, useState, useRef } from "react";
|
||||
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 }) => {
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
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) {
|
||||
setIsConnected(true);
|
||||
}
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
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])
|
||||
return (
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
@@ -49,6 +89,7 @@ export const MessageLogger = ({ socket }) => {
|
||||
socket.addEventListener("message", handleMessage);
|
||||
|
||||
return () => {
|
||||
console.log('removing event listeners')
|
||||
socket.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [socket]);
|
||||
@@ -56,7 +97,7 @@ export const MessageLogger = ({ socket }) => {
|
||||
useEffect(() => {
|
||||
// Scroll to bottom when messages update
|
||||
if (bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" , block: 'nearest', inline: 'start'});
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
@@ -77,21 +118,21 @@ export const MessageLogger = ({ socket }) => {
|
||||
};
|
||||
|
||||
export const DraftAdmin = ({ draftSessionId }) => {
|
||||
const [latestMessage, setLatestMessage] = useState(null);
|
||||
const [connectedParticipants, setConnectedParticipants] = useState([]);
|
||||
const [draftPhase, setDraftPhase] = useState();
|
||||
|
||||
const socketRef = useRef(null);
|
||||
const socketRef = useWebSocket();
|
||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`;
|
||||
|
||||
useEffect(() => {
|
||||
if (socketRef.current) return;
|
||||
console.log('socket created')
|
||||
socketRef.current = new WebSocket(wsUrl);
|
||||
|
||||
socketRef.current.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
const { type, payload } = message;
|
||||
console.log(type, event)
|
||||
setLatestMessage(message);
|
||||
if (type == DraftMessage.REQUEST.JOIN_PARTICIPANT) {
|
||||
console.log('join request', data)
|
||||
}
|
||||
@@ -124,10 +165,11 @@ export const DraftAdmin = ({ draftSessionId }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<WebSocketContext url={wsUrl}>
|
||||
<div className="container draft-panel">
|
||||
<h3>Draft Admin Panel</h3>
|
||||
<WebSocketStatus socket={socketRef.current} />
|
||||
<MessageLogger socket={socketRef.current}></MessageLogger>
|
||||
<WebSocketStatus socket={socket} />
|
||||
{/* <MessageLogger socket={socketRef.current} /> */}
|
||||
<label>Connected Particpants</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -147,11 +189,11 @@ export const DraftAdmin = ({ draftSessionId }) => {
|
||||
Request status
|
||||
</button>
|
||||
</div>
|
||||
</WebSocketContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraftParticipant = ({ draftSessionId }) => {
|
||||
const [latestMessage, setLatestMessage] = useState(null);
|
||||
const socketRef = useRef(null);
|
||||
const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/participant`;
|
||||
|
||||
@@ -161,7 +203,6 @@ export const DraftParticipant = ({ draftSessionId }) => {
|
||||
socketRef.current.onmessage = (evt) => {
|
||||
const data = JSON.parse(evt.data);
|
||||
console.log(data)
|
||||
setLatestMessage(data);
|
||||
};
|
||||
|
||||
socketRef.current.onclose = () => {
|
||||
@@ -183,11 +224,7 @@ export const DraftParticipant = ({ draftSessionId }) => {
|
||||
<h3 >Draft Participant Panel</h3>
|
||||
<WebSocketStatus socket={socketRef.current} />
|
||||
<label>Latest Message</label>
|
||||
<input
|
||||
type="text"
|
||||
readOnly disabled
|
||||
value={latestMessage ? JSON.stringify(latestMessage) : ""}
|
||||
/>
|
||||
<MessageLogger socket={socketRef.current}></MessageLogger>
|
||||
</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'
|
||||
console.log("Webpack HMR loaded!");
|
||||
|
||||
import React from "react";
|
||||
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} />);
|
||||
|
||||
}
|
||||
if (draftAdminApp) {
|
||||
const root = createRoot(draftAdminApp);
|
||||
const draftId = draftAdminApp.dataset.draftId
|
||||
root.render(<DraftAdmin draftSessionId={draftId} />);
|
||||
}
|
||||
});
|
||||
const draftAdminRoot = document.getElementById("draft-admin-root");
|
||||
const draftPartipantRoot = document.getElementById("draft-participant-root")
|
||||
const {draftSessionId} = window; // from backend template
|
||||
|
||||
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;
|
||||
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,
|
||||
secure: false,
|
||||
},
|
||||
{
|
||||
context: (pathname) => pathname.startsWith('/ws/'),
|
||||
target: 'ws://localhost:8000',
|
||||
ws: true, // <-- enable websocket proxying
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
ignoreWarnings: [
|
||||
|
||||
Reference in New Issue
Block a user