* Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
320 lines
11 KiB
TypeScript
320 lines
11 KiB
TypeScript
import { useLoaderData, Link } from "react-router";
|
|
import { eq, asc, and, inArray } from "drizzle-orm";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { DraftGrid } from "~/components/DraftGrid";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useState, useEffect } from "react";
|
|
import { buildOwnerMap } from "~/lib/owner-map";
|
|
import { Button } from "~/components/ui/button";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
|
|
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) {
|
|
// Not public - check if the user is a league member or commissioner
|
|
const { userId } = await getAuth(args);
|
|
|
|
if (!userId) {
|
|
throw new Response("This draft board is not public", { status: 403 });
|
|
}
|
|
|
|
// Check if user is a commissioner
|
|
const isCommissioner = await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, season.leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
});
|
|
|
|
// Check if user has a team in this season
|
|
const hasTeam = await db.query.teams.findFirst({
|
|
where: and(
|
|
eq(schema.teams.seasonId, seasonId),
|
|
eq(schema.teams.ownerId, userId)
|
|
),
|
|
});
|
|
|
|
if (!isCommissioner && !hasTeam) {
|
|
throw new Response("You don't have access to this draft board", { status: 403 });
|
|
}
|
|
}
|
|
|
|
// 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.participants,
|
|
sport: schema.sports,
|
|
scoringPattern: schema.sportsSeasons.scoringPattern,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.participants,
|
|
eq(schema.draftPicks.participantId, schema.participants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.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 ownerMap = await buildOwnerMap(draftSlots);
|
|
|
|
const 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.participantResults.participantId,
|
|
sportsSeasonId: schema.participantResults.sportsSeasonId,
|
|
finalPosition: schema.participantResults.finalPosition,
|
|
isPartialScore: schema.participantResults.isPartialScore,
|
|
})
|
|
.from(schema.participantResults)
|
|
.where(inArray(schema.participantResults.participantId, participantIds));
|
|
|
|
const resultByParticipant = new Map(
|
|
results.map((r) => [r.participantId, r])
|
|
);
|
|
|
|
const maxPoints = season.pointsFor1st;
|
|
|
|
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,
|
|
};
|
|
|
|
for (const pick of draftPicks) {
|
|
const result = resultByParticipant.get(pick.participant.id);
|
|
|
|
if (!result || result.finalPosition === null) {
|
|
coronaStates[pick.participant.id] = { type: "pending" };
|
|
continue;
|
|
}
|
|
|
|
if (result.finalPosition === 0 && !result.isPartialScore) {
|
|
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
|
|
continue;
|
|
}
|
|
|
|
if (result.finalPosition > 0) {
|
|
const isBracket =
|
|
pick.scoringPattern === "playoff_bracket";
|
|
const templateId = isBracket
|
|
? bracketTemplateBySportsSeason.get(
|
|
pick.participant.sportsSeasonId
|
|
) ?? null
|
|
: null;
|
|
const points = isBracket
|
|
? calculateBracketPoints(
|
|
result.finalPosition,
|
|
scoringRules,
|
|
templateId
|
|
)
|
|
: calculateFantasyPoints(result.finalPosition, scoringRules);
|
|
const brightness =
|
|
maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
|
|
coronaStates[pick.participant.id] = {
|
|
type: "scored",
|
|
brightness,
|
|
points,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
coronaStates[pick.participant.id] = { type: "pending" };
|
|
}
|
|
}
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
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="/logomark.svg" 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 px-4 py-4">
|
|
<DraftGrid
|
|
draftSlots={draftSlots}
|
|
draftGrid={draftGrid}
|
|
currentPick={currentPick}
|
|
ownerMap={ownerMap}
|
|
coronaStates={coronaStates}
|
|
seasonStatus={season.status}
|
|
draftPaused={season.draftPaused}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|