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:
2025-08-08 12:50:33 -05:00
parent c9ce7a36d0
commit 9b6b3391e6
28 changed files with 804 additions and 171 deletions

0
api/__init__.py Normal file
View File

3
api/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
api/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

View File

3
api/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

59
api/serializers.py Normal file
View 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
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
api/urls.py Normal file
View 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
View 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")),
)
)

View File

@@ -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,
}
}

View File

@@ -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>

View File

@@ -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",

View File

@@ -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:

View File

@@ -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

View File

@@ -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(

View File

@@ -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()

View File

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

View File

@@ -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 %}

View File

@@ -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 %}

View 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);

View 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>
);
};

View 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>
);
};

View File

@@ -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",
]

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
}

View File

@@ -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;
}
}
}

View File

@@ -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: [