brackt/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx
Chris Parsons 02fa285585
Fix mobile spacing for draft board and dashboard layout (#13, #329) (#404)
Reduce double-stacked padding on draft board page from 64px to 32px on
mobile by using responsive p-2/px-2 md:p-4/md:px-4 classes.

Replace dashboard CSS grid with flex layout so My Leagues and Create
League stack naturally at the top without being stretched apart by the
row-span-2 events column. Mobile order preserved: My Leagues, Events,
Create League.
2026-05-10 19:51:34 -07:00

276 lines
9.7 KiB
TypeScript

import { useLoaderData, Link } from "react-router";
import { eq, asc, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { requireLeagueAccess } from "~/lib/auth";
import { DraftGrid } from "~/components/DraftGrid";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useState, useEffect } from "react";
import { findUsersByIds, getUserDisplayName } from "~/models/user";
import { resolveUserAvatarData } from "~/lib/avatar-data";
import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react";
import logomarkUrl from "../../../public/logomark.svg?url";
import { computeCoronaStates } from "~/lib/corona-states";
import type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Draft Board — ${data?.season?.league?.name ?? "League"} - Brackt` }];
}
export async function loader(args: Route.LoaderArgs) {
const { params } = args;
const { leagueId, seasonId } = params;
if (!seasonId) {
throw new Response("Season ID is required", { status: 400 });
}
const db = database();
// Get season details
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
with: {
league: true,
},
});
if (!season) {
throw new Response("Season not found", { status: 404 });
}
// Validate that the season actually belongs to the league in the URL
if (season.leagueId !== leagueId) {
throw new Response("Season not found", { status: 404 });
}
// Check access: public boards are accessible to everyone without auth
if (!season.league.isPublicDraftBoard) {
await requireLeagueAccess(args, {
leagueId: season.leagueId,
seasonId,
db,
unauthMessage: "This draft board is not public",
unauthorizedMessage: "You don't have access to this draft board",
});
}
// Get draft slots (draft order)
const draftSlots = await db
.select({
id: schema.draftSlots.id,
draftOrder: schema.draftSlots.draftOrder,
team: schema.teams,
})
.from(schema.draftSlots)
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
.where(eq(schema.draftSlots.seasonId, seasonId))
.orderBy(asc(schema.draftSlots.draftOrder));
// Get all draft picks with participant and sport info
const draftPicks = await db
.select({
id: schema.draftPicks.id,
pickNumber: schema.draftPicks.pickNumber,
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
team: schema.teams,
participant: schema.seasonParticipants,
sport: schema.sports,
scoringPattern: schema.sportsSeasons.scoringPattern,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonParticipants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber));
const uniqueOwnerIds = [...new Set(
draftSlots.map((s) => s.team.ownerId).filter((id): id is string => id !== null)
)];
const ownerUserRows = uniqueOwnerIds.length ? await findUsersByIds(uniqueOwnerIds) : [];
const ownerUserById = new Map(ownerUserRows.map((u) => [u.id, u]));
const ownerMap: Record<string, string> = {};
const enrichedDraftSlots = draftSlots.map((slot) => {
const ownerUser = slot.team.ownerId ? ownerUserById.get(slot.team.ownerId) : undefined;
const displayName = ownerUser ? getUserDisplayName(ownerUser) : null;
if (slot.team.ownerId && displayName) ownerMap[slot.team.id] = displayName;
return {
...slot,
team: { ...slot.team, ownerAvatarData: ownerUser ? resolveUserAvatarData(ownerUser) : null },
};
});
let coronaStates: Record<string, CoronaState> = {};
if (draftPicks.length > 0) {
const participantIds = draftPicks.map((p) => p.participant.id);
const sportsSeasonIds = [...new Set(draftPicks.map((p) => p.participant.sportsSeasonId))];
const results = await db
.select({
participantId: schema.seasonParticipantResults.participantId,
sportsSeasonId: schema.seasonParticipantResults.sportsSeasonId,
finalPosition: schema.seasonParticipantResults.finalPosition,
isPartialScore: schema.seasonParticipantResults.isPartialScore,
})
.from(schema.seasonParticipantResults)
.where(inArray(schema.seasonParticipantResults.participantId, participantIds));
const resultByParticipant = new Map(results.map((r) => [r.participantId, r]));
const bracketTemplateBySportsSeason = new Map<string, string | null>();
if (sportsSeasonIds.length > 0) {
const events = await db
.select({
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
bracketTemplateId: schema.scoringEvents.bracketTemplateId,
})
.from(schema.scoringEvents)
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
for (const ev of events) {
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
bracketTemplateBySportsSeason.set(ev.sportsSeasonId, ev.bracketTemplateId);
}
}
}
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
coronaStates = computeCoronaStates(
draftPicks,
resultByParticipant,
bracketTemplateBySportsSeason,
scoringRules,
season.pointsFor1st,
);
}
return {
season,
draftSlots: enrichedDraftSlots,
draftPicks,
ownerMap,
coronaStates,
};
}
export default function DraftBoard() {
const { season, draftSlots, draftPicks: initialPicks, ownerMap, coronaStates } = useLoaderData<typeof loader>();
const { isConnected, on, off } = useDraftSocket(season.id);
const [picks, setPicks] = useState(initialPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
// Listen for new picks (only if draft is still active)
useEffect(() => {
if (season.status !== "draft") return;
type PickShape = (typeof initialPicks)[number];
const handlePickMade = (data: unknown) => {
const pickData = data as { pick: PickShape; nextPickNumber: number };
setPicks((prev) => [...prev, pickData.pick]);
setCurrentPick(pickData.nextPickNumber);
};
on("pick-made", handlePickMade);
return () => {
off("pick-made", handlePickMade);
};
}, [on, off, season.status]);
// Generate draft grid
const totalTeams = draftSlots.length;
const totalRounds = season.draftRounds || 1;
type PickItem = (typeof initialPicks)[number];
const draftGrid: Array<Array<PickItem | null>> = [];
for (let round = 0; round < totalRounds; round++) {
const roundPicks: Array<PickItem | null> = [];
for (let teamIndex = 0; teamIndex < totalTeams; teamIndex++) {
const pickNumber = round * totalTeams + teamIndex + 1;
const pick = picks.find((p) => p.pickNumber === pickNumber);
roundPicks.push(pick || null);
}
draftGrid.push(roundPicks);
}
const isDraftActive = season.status === "draft";
const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1;
return (
<div className="min-h-screen bg-background">
<div className="border-b bg-card sticky top-0 z-10">
<div className="w-full px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/">
<img src={logomarkUrl} alt="Brackt" className="h-8" />
</Link>
<h1 className="text-2xl font-bold">
{season.league.name} Draft Board
</h1>
</div>
<div className="flex items-center gap-4">
{isDraftActive && (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>Round {currentRound}</span>
<span>Pick {currentPick}</span>
<div className="flex items-center gap-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? "bg-emerald-500" : "bg-coral-accent"
}`}
/>
<span className="font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${season.leagueId}`}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to League
</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Draft Grid */}
<div className="w-full py-4 px-2 md:px-4">
<DraftGrid
draftSlots={draftSlots}
draftGrid={draftGrid}
currentPick={currentPick}
ownerMap={ownerMap}
coronaStates={coronaStates}
seasonStatus={season.status}
draftPaused={season.draftPaused}
/>
</div>
</div>
);
}