feat: add queue management UI and API endpoints for draft room
This commit is contained in:
parent
83e40bd59f
commit
1ad14bfef1
6 changed files with 338 additions and 11 deletions
|
|
@ -12,6 +12,10 @@ export default [
|
||||||
),
|
),
|
||||||
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
||||||
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
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("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("test-socket", "routes/test-socket.tsx"),
|
route("test-socket", "routes/test-socket.tsx"),
|
||||||
|
|
|
||||||
53
app/routes/api/queue.add.ts
Normal file
53
app/routes/api/queue.add.ts
Normal 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 });
|
||||||
|
}
|
||||||
42
app/routes/api/queue.clear.ts
Normal file
42
app/routes/api/queue.clear.ts
Normal 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 });
|
||||||
|
}
|
||||||
46
app/routes/api/queue.remove.ts
Normal file
46
app/routes/api/queue.remove.ts
Normal 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 });
|
||||||
|
}
|
||||||
49
app/routes/api/queue.reorder.ts
Normal file
49
app/routes/api/queue.reorder.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
|
@ -136,7 +136,7 @@ export async function loader(args: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DraftRoom() {
|
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>();
|
useLoaderData<typeof loader>();
|
||||||
const { isConnected, on, off } = useDraftSocket(season.id);
|
const { isConnected, on, off } = useDraftSocket(season.id);
|
||||||
const [picks, setPicks] = useState(draftPicks);
|
const [picks, setPicks] = useState(draftPicks);
|
||||||
|
|
@ -144,6 +144,7 @@ export default function DraftRoom() {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [hideDrafted, setHideDrafted] = useState(true);
|
const [hideDrafted, setHideDrafted] = useState(true);
|
||||||
const [sportFilter, setSportFilter] = useState<string>("all");
|
const [sportFilter, setSportFilter] = useState<string>("all");
|
||||||
|
const [queue, setQueue] = useState(userQueue);
|
||||||
|
|
||||||
// Listen for new picks from other users
|
// Listen for new picks from other users
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -160,6 +161,60 @@ export default function DraftRoom() {
|
||||||
};
|
};
|
||||||
}, [on, off]);
|
}, [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
|
// Calculate current round
|
||||||
const currentRound = Math.ceil(currentPick / draftSlots.length);
|
const currentRound = Math.ceil(currentPick / draftSlots.length);
|
||||||
|
|
||||||
|
|
@ -359,9 +414,59 @@ export default function DraftRoom() {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Recent Picks */}
|
||||||
<div>
|
<div className={userTeam ? "" : "lg:col-span-2"}>
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<h2 className="text-xl font-semibold mb-4">Recent Picks</h2>
|
<h2 className="text-xl font-semibold mb-4">Recent Picks</h2>
|
||||||
{picks.length === 0 ? (
|
{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">Participant</th>
|
||||||
<th className="text-left p-3 font-semibold">Sport</th>
|
<th className="text-left p-3 font-semibold">Sport</th>
|
||||||
<th className="text-right p-3 font-semibold">EV</th>
|
<th className="text-right p-3 font-semibold">EV</th>
|
||||||
|
{userTeam && <th className="text-right p-3 font-semibold">Queue</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -469,21 +575,16 @@ export default function DraftRoom() {
|
||||||
) : (
|
) : (
|
||||||
filteredParticipants.map((participant: any) => {
|
filteredParticipants.map((participant: any) => {
|
||||||
const isDrafted = draftedParticipantIds.has(participant.id);
|
const isDrafted = draftedParticipantIds.has(participant.id);
|
||||||
|
const isInQueue = queue.some((item: any) => item.participantId === participant.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
className={`border-t transition-colors ${
|
className={`border-t transition-colors ${
|
||||||
isDrafted
|
isDrafted
|
||||||
? "bg-muted/50 opacity-60 cursor-not-allowed"
|
? "bg-muted/50 opacity-60"
|
||||||
: "hover:bg-muted/50 cursor-pointer"
|
: "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">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -493,6 +594,11 @@ export default function DraftRoom() {
|
||||||
Drafted
|
Drafted
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isInQueue && !isDrafted && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Queued
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<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">
|
<td className="p-3 text-right font-mono font-semibold">
|
||||||
{participant.expectedValue}
|
{participant.expectedValue}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue