diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/admin.py b/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/apps.py b/api/apps.py new file mode 100644 index 0000000..66656fd --- /dev/null +++ b/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api' diff --git a/api/migrations/__init__.py b/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/models.py b/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/api/serializers.py b/api/serializers.py new file mode 100644 index 0000000..5362de8 --- /dev/null +++ b/api/serializers.py @@ -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 + ) \ No newline at end of file diff --git a/api/tests.py b/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/urls.py b/api/urls.py new file mode 100644 index 0000000..59fb7de --- /dev/null +++ b/api/urls.py @@ -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 \ No newline at end of file diff --git a/api/views.py b/api/views.py new file mode 100644 index 0000000..100a889 --- /dev/null +++ b/api/views.py @@ -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// + 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")), + ) + ) \ No newline at end of file diff --git a/boxofficefantasy.code-workspace b/boxofficefantasy.code-workspace index e286afb..5ed7551 100644 --- a/boxofficefantasy.code-workspace +++ b/boxofficefantasy.code-workspace @@ -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, } } diff --git a/boxofficefantasy/templates/league.dj.html b/boxofficefantasy/templates/league.dj.html index a4c5528..46d20c2 100644 --- a/boxofficefantasy/templates/league.dj.html +++ b/boxofficefantasy/templates/league.dj.html @@ -58,7 +58,7 @@ diff --git a/boxofficefantasy_project/settings.py b/boxofficefantasy_project/settings.py index ac57ace..5dde478 100644 --- a/boxofficefantasy_project/settings.py +++ b/boxofficefantasy_project/settings.py @@ -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", diff --git a/boxofficefantasy_project/urls.py b/boxofficefantasy_project/urls.py index 91c6b19..d7ebdd4 100644 --- a/boxofficefantasy_project/urls.py +++ b/boxofficefantasy_project/urls.py @@ -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: diff --git a/draft/constants.py b/draft/constants.py index 952ae53..8a951fb 100644 --- a/draft/constants.py +++ b/draft/constants.py @@ -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 diff --git a/draft/consumers.py b/draft/consumers.py index 532a9fc..a328d00 100644 --- a/draft/consumers.py +++ b/draft/consumers.py @@ -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( diff --git a/draft/models.py b/draft/models.py index 1ecbaa6..b116979 100644 --- a/draft/models.py +++ b/draft/models.py @@ -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() diff --git a/draft/state.py b/draft/state.py index 4f52b87..d3279c2 100644 --- a/draft/state.py +++ b/draft/state.py @@ -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(), } \ No newline at end of file diff --git a/draft/templates/draft/room.dj.html b/draft/templates/draft/room.dj.html index 054f072..e1feb50 100644 --- a/draft/templates/draft/room.dj.html +++ b/draft/templates/draft/room.dj.html @@ -2,10 +2,13 @@ {% block content %}

Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}

{% load static %} -
+ +
{% if DEBUG %} {% else %} {% endif %} -{% endblock %} \ No newline at end of file +{% endblock content %} \ No newline at end of file diff --git a/draft/templates/draft/room_admin.dj.html b/draft/templates/draft/room_admin.dj.html index 09d054c..72b2883 100644 --- a/draft/templates/draft/room_admin.dj.html +++ b/draft/templates/draft/room_admin.dj.html @@ -2,7 +2,10 @@ {% block content %}

Draft Room: {{ league.name }} – {{ season.label }} {{ season.year }}

{% load static %} -
+ +
{% if DEBUG %} {% else %} diff --git a/frontend/src/apps/draft/WebSocketContext.jsx b/frontend/src/apps/draft/WebSocketContext.jsx new file mode 100644 index 0000000..4c50961 --- /dev/null +++ b/frontend/src/apps/draft/WebSocketContext.jsx @@ -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 ( + + {children} + + ); +}; + +export const useWebSocket = () => useContext(WebSocketContext); \ No newline at end of file diff --git a/frontend/src/apps/draft/admin/DraftAdmin.jsx b/frontend/src/apps/draft/admin/DraftAdmin.jsx new file mode 100644 index 0000000..2e179f5 --- /dev/null +++ b/frontend/src/apps/draft/admin/DraftAdmin.jsx @@ -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 ( +
+ + + {listItems.map((p, i) => ( +
  • + {p?.full_name} +
    +
  • + ))} +
    +
    + ) +} + +const DraftPhaseDisplay = ({ draftPhase }) => { + return ( +
    + +
      + { + DraftPhases.map((p) => ( +
    1. + {p} +
    2. + )) + } +
    +
    + ) +} + +const DraftOrder = ({ socket, draftOrder }) => { + console.log("in component", draftOrder) + return ( +
    + +
      + { + draftOrder.map((p) => ( +
    1. + {p} +
    2. + )) + } +
    +
    + ) +} + +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 ( +
    +

    Draft Admin Panel

    + + {/* */} + + + + + + +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/apps/draft/common/WebSocketStatus.jsx b/frontend/src/apps/draft/common/WebSocketStatus.jsx new file mode 100644 index 0000000..9324233 --- /dev/null +++ b/frontend/src/apps/draft/common/WebSocketStatus.jsx @@ -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 ( +
    + + {isConnected ? "Connected" : "Disconnected"} + +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/apps/draft/constants.js b/frontend/src/apps/draft/constants.js index 7382a15..d0d23c1 100644 --- a/frontend/src/apps/draft/constants.js +++ b/frontend/src/apps/draft/constants.js @@ -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, -} \ No newline at end of file +} + +export const DraftPhases = [ + "waiting", + "determine_order", + "nomination", + "bidding", + "award", + "finalize", +] \ No newline at end of file diff --git a/frontend/src/apps/draft/index.jsx b/frontend/src/apps/draft/index.jsx index b75a414..0cc51d1 100644 --- a/frontend/src/apps/draft/index.jsx +++ b/frontend/src/apps/draft/index.jsx @@ -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 ( + + {children} + + ); +}; + +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 (
    @@ -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 ( +

    Draft Admin Panel

    - - + + {/* */} { Request status
    +
    ); }; 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 }) => {

    Draft Participant Panel

    - +
    ); }; \ No newline at end of file diff --git a/frontend/src/apps/draft/participant/DraftParticipant.jsx b/frontend/src/apps/draft/participant/DraftParticipant.jsx new file mode 100644 index 0000000..5e50dfd --- /dev/null +++ b/frontend/src/apps/draft/participant/DraftParticipant.jsx @@ -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 ( +
    +

    Draft Admin Panel

    + + {/* */} + + + + + + +
    + ); +}; \ No newline at end of file diff --git a/frontend/src/index.js b/frontend/src/index.js index a3a6482..6f74ec8 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -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(); - } - if (draftAdminApp) { - const root = createRoot(draftAdminApp); - const draftId = draftAdminApp.dataset.draftId - root.render(); - } -}); +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( + + + + ); +} +if (draftAdminRoot) { + const wsUrl = `ws://${window.location.host}/ws/draft/session/${draftSessionId}/admin`; + createRoot(draftAdminRoot).render( + + + + ); +} diff --git a/frontend/src/scss/styles.scss b/frontend/src/scss/styles.scss index 701e39d..8a31595 100644 --- a/frontend/src/scss/styles.scss +++ b/frontend/src/scss/styles.scss @@ -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; + } + } +} diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 5066431..c983b1f 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -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: [