feat: add queue management UI and API endpoints for draft room

This commit is contained in:
Chris Parsons 2025-10-17 17:42:40 -07:00
parent 83e40bd59f
commit 1ad14bfef1
6 changed files with 338 additions and 11 deletions

View file

@ -12,6 +12,10 @@ export default [
),
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
route("api/queue/add", "routes/api/queue.add.ts"),
route("api/queue/remove", "routes/api/queue.remove.ts"),
route("api/queue/clear", "routes/api/queue.clear.ts"),
route("api/queue/reorder", "routes/api/queue.reorder.ts"),
route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),
route("test-socket", "routes/test-socket.tsx"),

View file

@ -0,0 +1,53 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { addToQueue, getTeamQueue } from "~/models/draft-queue";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const seasonId = formData.get("seasonId");
const teamId = formData.get("teamId");
const participantId = formData.get("participantId");
if (!seasonId || !teamId || !participantId) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const db = database();
// Verify user owns this team
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId as string),
});
if (!team || team.ownerId !== userId) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
// Get current queue to determine position
const currentQueue = await getTeamQueue(teamId as string);
const queuePosition = currentQueue.length + 1;
// Add to queue
const queueItem = await addToQueue({
seasonId: seasonId as string,
teamId: teamId as string,
participantId: participantId as string,
queuePosition,
});
// TODO: Emit socket event for real-time update
// const io = getSocketIO();
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [...currentQueue, queueItem] });
return Response.json({ success: true, queueItem });
}

View file

@ -0,0 +1,42 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { clearTeamQueue } from "~/models/draft-queue";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const teamId = formData.get("teamId");
if (!teamId) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const db = database();
// Verify user owns this team
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId as string),
});
if (!team || team.ownerId !== userId) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
// Clear queue
await clearTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update
// const io = getSocketIO();
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [] });
return Response.json({ success: true });
}

View file

@ -0,0 +1,46 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { removeFromQueue, getTeamQueue } from "~/models/draft-queue";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const queueId = formData.get("queueId");
const teamId = formData.get("teamId");
if (!queueId || !teamId) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const db = database();
// Verify user owns this team
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId as string),
});
if (!team || team.ownerId !== userId) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
// Remove from queue
await removeFromQueue(queueId as string);
// Get updated queue
const updatedQueue = await getTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update
// const io = getSocketIO();
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue });
return Response.json({ success: true, queue: updatedQueue });
}

View file

@ -0,0 +1,49 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq } from "drizzle-orm";
import { reorderQueueWithSeason, getTeamQueue } from "~/models/draft-queue";
export async function action(args: any) {
const { request } = args;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const teamId = formData.get("teamId");
const seasonId = formData.get("seasonId");
const participantIdsJson = formData.get("participantIds");
if (!teamId || !seasonId || !participantIdsJson) {
return Response.json({ error: "Missing required fields" }, { status: 400 });
}
const participantIds = JSON.parse(participantIdsJson as string);
const db = database();
// Verify user owns this team
const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId as string),
});
if (!team || team.ownerId !== userId) {
return Response.json({ error: "Unauthorized" }, { status: 403 });
}
// Reorder queue
await reorderQueueWithSeason(seasonId as string, teamId as string, participantIds);
// Get updated queue
const updatedQueue = await getTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update
// const io = getSocketIO();
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue });
return Response.json({ success: true, queue: updatedQueue });
}

View file

@ -136,7 +136,7 @@ export async function loader(args: any) {
}
export default function DraftRoom() {
const { season, draftSlots, draftPicks, availableParticipants, userTeam: _userTeam, isCommissioner: _isCommissioner } =
const { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, isCommissioner: _isCommissioner } =
useLoaderData<typeof loader>();
const { isConnected, on, off } = useDraftSocket(season.id);
const [picks, setPicks] = useState(draftPicks);
@ -144,6 +144,7 @@ export default function DraftRoom() {
const [searchQuery, setSearchQuery] = useState("");
const [hideDrafted, setHideDrafted] = useState(true);
const [sportFilter, setSportFilter] = useState<string>("all");
const [queue, setQueue] = useState(userQueue);
// Listen for new picks from other users
useEffect(() => {
@ -160,6 +161,60 @@ export default function DraftRoom() {
};
}, [on, off]);
// Queue handlers
const handleAddToQueue = async (participantId: string) => {
if (!userTeam) return;
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
const response = await fetch("/api/queue/add", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
setQueue((prev: any) => [...prev, data.queueItem]);
}
};
const handleRemoveFromQueue = async (queueId: string) => {
if (!userTeam) return;
const formData = new FormData();
formData.append("queueId", queueId);
formData.append("teamId", userTeam.id);
const response = await fetch("/api/queue/remove", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
setQueue(data.queue);
}
};
const handleClearQueue = async () => {
if (!userTeam) return;
const formData = new FormData();
formData.append("teamId", userTeam.id);
const response = await fetch("/api/queue/clear", {
method: "POST",
body: formData,
});
if (response.ok) {
setQueue([]);
}
};
// Calculate current round
const currentRound = Math.ceil(currentPick / draftSlots.length);
@ -359,9 +414,59 @@ export default function DraftRoom() {
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* My Queue */}
{userTeam && (
<div>
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">My Queue ({queue.length})</h2>
{queue.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleClearQueue}
>
Clear
</Button>
)}
</div>
{queue.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
Click participants to add to your queue
</p>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{queue.map((item: any, index: number) => (
<div
key={item.id}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex items-center gap-3">
<Badge variant="default">{index + 1}</Badge>
<div>
<p className="font-semibold text-sm">
{availableParticipants.find((p: any) => p.id === item.participantId)?.name || "Unknown"}
</p>
</div>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => handleRemoveFromQueue(item.id)}
>
Remove
</Button>
</div>
))}
</div>
)}
</Card>
</div>
)}
{/* Recent Picks */}
<div>
<div className={userTeam ? "" : "lg:col-span-2"}>
<Card className="p-4">
<h2 className="text-xl font-semibold mb-4">Recent Picks</h2>
{picks.length === 0 ? (
@ -457,6 +562,7 @@ export default function DraftRoom() {
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">EV</th>
{userTeam && <th className="text-right p-3 font-semibold">Queue</th>}
</tr>
</thead>
<tbody>
@ -469,21 +575,16 @@ export default function DraftRoom() {
) : (
filteredParticipants.map((participant: any) => {
const isDrafted = draftedParticipantIds.has(participant.id);
const isInQueue = queue.some((item: any) => item.participantId === participant.id);
return (
<tr
key={participant.id}
className={`border-t transition-colors ${
isDrafted
? "bg-muted/50 opacity-60 cursor-not-allowed"
: "hover:bg-muted/50 cursor-pointer"
? "bg-muted/50 opacity-60"
: "hover:bg-muted/50"
}`}
onClick={() => {
if (!isDrafted) {
// TODO: Add to queue functionality (Phase 7)
console.log("Add to queue:", participant.name);
}
}}
>
<td className="p-3">
<div className="flex items-center gap-2">
@ -493,6 +594,11 @@ export default function DraftRoom() {
Drafted
</Badge>
)}
{isInQueue && !isDrafted && (
<Badge variant="default" className="text-xs">
Queued
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
@ -501,6 +607,33 @@ export default function DraftRoom() {
<td className="p-3 text-right font-mono font-semibold">
{participant.expectedValue}
</td>
{userTeam && (
<td className="p-3 text-right">
{!isDrafted && !isInQueue && (
<Button
variant="outline"
size="sm"
onClick={() => handleAddToQueue(participant.id)}
>
Add
</Button>
)}
{!isDrafted && isInQueue && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueItem = queue.find((item: any) => item.participantId === participant.id);
if (queueItem) {
handleRemoveFromQueue(queueItem.id);
}
}}
>
Remove
</Button>
)}
</td>
)}
</tr>
);
})