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

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