From 5c957249adc50bb89153180e1658b5680d907a5b Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 11 Oct 2025 00:07:39 -0700 Subject: [PATCH] feat: add fantasy league schema and new league routes --- app/components/ui/button.tsx | 60 +++++++ app/components/ui/card.tsx | 92 +++++++++++ app/components/ui/input.tsx | 21 +++ app/components/ui/label.tsx | 22 +++ app/components/ui/select.tsx | 185 ++++++++++++++++++++++ app/models/index.ts | 4 + app/models/league.ts | 60 +++++++ app/models/season.ts | 79 ++++++++++ app/models/team.ts | 84 ++++++++++ app/routes.ts | 8 +- app/routes/leagues/$leagueId.tsx | 130 ++++++++++++++++ app/routes/leagues/new.tsx | 152 ++++++++++++++++++ app/welcome/welcome.tsx | 8 +- database/schema.ts | 40 ++++- drizzle/0001_serious_la_nuit.sql | 38 +++++ drizzle/meta/0001_snapshot.json | 260 +++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + package-lock.json | 210 +++++++++++++++++++++++++ package.json | 3 + 19 files changed, 1459 insertions(+), 4 deletions(-) create mode 100644 app/components/ui/button.tsx create mode 100644 app/components/ui/card.tsx create mode 100644 app/components/ui/input.tsx create mode 100644 app/components/ui/label.tsx create mode 100644 app/components/ui/select.tsx create mode 100644 app/models/index.ts create mode 100644 app/models/league.ts create mode 100644 app/models/season.ts create mode 100644 app/models/team.ts create mode 100644 app/routes/leagues/$leagueId.tsx create mode 100644 app/routes/leagues/new.tsx create mode 100644 drizzle/0001_serious_la_nuit.sql create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/app/components/ui/button.tsx b/app/components/ui/button.tsx new file mode 100644 index 0000000..1c89dc7 --- /dev/null +++ b/app/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "app/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/app/components/ui/card.tsx b/app/components/ui/card.tsx new file mode 100644 index 0000000..0ebaf40 --- /dev/null +++ b/app/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "app/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/app/components/ui/input.tsx b/app/components/ui/input.tsx new file mode 100644 index 0000000..3817a8f --- /dev/null +++ b/app/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "app/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/app/components/ui/label.tsx b/app/components/ui/label.tsx new file mode 100644 index 0000000..a08292d --- /dev/null +++ b/app/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "app/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/app/components/ui/select.tsx b/app/components/ui/select.tsx new file mode 100644 index 0000000..39bde28 --- /dev/null +++ b/app/components/ui/select.tsx @@ -0,0 +1,185 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "app/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/app/models/index.ts b/app/models/index.ts new file mode 100644 index 0000000..f44153d --- /dev/null +++ b/app/models/index.ts @@ -0,0 +1,4 @@ +// Re-export all model functions and types +export * from "./league"; +export * from "./season"; +export * from "./team"; diff --git a/app/models/league.ts b/app/models/league.ts new file mode 100644 index 0000000..195da9c --- /dev/null +++ b/app/models/league.ts @@ -0,0 +1,60 @@ +import { eq } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type League = typeof schema.leagues.$inferSelect; +export type NewLeague = typeof schema.leagues.$inferInsert; + +export async function createLeague(data: NewLeague): Promise { + const db = database(); + const [league] = await db + .insert(schema.leagues) + .values(data) + .returning(); + return league; +} + +export async function findLeagueById(id: string): Promise { + const db = database(); + return await db.query.leagues.findFirst({ + where: eq(schema.leagues.id, id), + }); +} + +export async function findLeaguesByCreator(userId: string): Promise { + const db = database(); + return await db.query.leagues.findMany({ + where: eq(schema.leagues.createdBy, userId), + orderBy: (leagues, { desc }) => [desc(leagues.createdAt)], + }); +} + +export async function updateLeague( + id: string, + data: Partial +): Promise { + const db = database(); + const [league] = await db + .update(schema.leagues) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.leagues.id, id)) + .returning(); + return league; +} + +export async function deleteLeague(id: string): Promise { + const db = database(); + await db.delete(schema.leagues).where(eq(schema.leagues.id, id)); +} + +export async function listLeagues(options?: { + limit?: number; + offset?: number; +}): Promise { + const db = database(); + return await db.query.leagues.findMany({ + limit: options?.limit, + offset: options?.offset, + orderBy: (leagues, { desc }) => [desc(leagues.createdAt)], + }); +} diff --git a/app/models/season.ts b/app/models/season.ts new file mode 100644 index 0000000..28433bb --- /dev/null +++ b/app/models/season.ts @@ -0,0 +1,79 @@ +import { eq, and } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type Season = typeof schema.seasons.$inferSelect; +export type NewSeason = typeof schema.seasons.$inferInsert; +export type SeasonStatus = "pre_draft" | "draft" | "active" | "completed"; + +export async function createSeason(data: NewSeason): Promise { + const db = database(); + const [season] = await db + .insert(schema.seasons) + .values(data) + .returning(); + return season; +} + +export async function findSeasonById(id: string): Promise { + const db = database(); + return await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, id), + }); +} + +export async function findSeasonsByLeagueId(leagueId: string): Promise { + const db = database(); + return await db.query.seasons.findMany({ + where: eq(schema.seasons.leagueId, leagueId), + orderBy: (seasons, { desc }) => [desc(seasons.year)], + }); +} + +export async function findCurrentSeason( + leagueId: string +): Promise { + const db = database(); + return await db.query.seasons.findFirst({ + where: eq(schema.seasons.leagueId, leagueId), + orderBy: (seasons, { desc }) => [desc(seasons.year)], + }); +} + +export async function findSeasonByLeagueAndYear( + leagueId: string, + year: number +): Promise { + const db = database(); + return await db.query.seasons.findFirst({ + where: and( + eq(schema.seasons.leagueId, leagueId), + eq(schema.seasons.year, year) + ), + }); +} + +export async function updateSeason( + id: string, + data: Partial +): Promise { + const db = database(); + const [season] = await db + .update(schema.seasons) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.seasons.id, id)) + .returning(); + return season; +} + +export async function updateSeasonStatus( + id: string, + status: SeasonStatus +): Promise { + return await updateSeason(id, { status }); +} + +export async function deleteSeason(id: string): Promise { + const db = database(); + await db.delete(schema.seasons).where(eq(schema.seasons.id, id)); +} diff --git a/app/models/team.ts b/app/models/team.ts new file mode 100644 index 0000000..20c9b12 --- /dev/null +++ b/app/models/team.ts @@ -0,0 +1,84 @@ +import { eq, and, isNull } from "drizzle-orm"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; + +export type Team = typeof schema.teams.$inferSelect; +export type NewTeam = typeof schema.teams.$inferInsert; + +export async function createTeam(data: NewTeam): Promise { + const db = database(); + const [team] = await db.insert(schema.teams).values(data).returning(); + return team; +} + +export async function createManyTeams(teams: NewTeam[]): Promise { + const db = database(); + return await db.insert(schema.teams).values(teams).returning(); +} + +export async function findTeamById(id: string): Promise { + const db = database(); + return await db.query.teams.findFirst({ + where: eq(schema.teams.id, id), + }); +} + +export async function findTeamsBySeasonId(seasonId: string): Promise { + const db = database(); + return await db.query.teams.findMany({ + where: eq(schema.teams.seasonId, seasonId), + orderBy: (teams, { asc }) => [asc(teams.name)], + }); +} + +export async function findTeamsByOwnerId(ownerId: string): Promise { + const db = database(); + return await db.query.teams.findMany({ + where: eq(schema.teams.ownerId, ownerId), + orderBy: (teams, { asc }) => [asc(teams.name)], + }); +} + +export async function findAvailableTeams(seasonId: string): Promise { + const db = database(); + return await db.query.teams.findMany({ + where: and( + eq(schema.teams.seasonId, seasonId), + isNull(schema.teams.ownerId) + ), + orderBy: (teams, { asc }) => [asc(teams.name)], + }); +} + +export async function updateTeam( + id: string, + data: Partial +): Promise { + const db = database(); + const [team] = await db + .update(schema.teams) + .set({ ...data, updatedAt: new Date() }) + .where(eq(schema.teams.id, id)) + .returning(); + return team; +} + +export async function assignTeamOwner( + id: string, + ownerId: string +): Promise { + return await updateTeam(id, { ownerId }); +} + +export async function removeTeamOwner(id: string): Promise { + return await updateTeam(id, { ownerId: null }); +} + +export async function renameTeam(id: string, name: string): Promise { + return await updateTeam(id, { name }); +} + +export async function deleteTeam(id: string): Promise { + const db = database(); + await db.delete(schema.teams).where(eq(schema.teams.id, id)); +} diff --git a/app/routes.ts b/app/routes.ts index 102b402..e60ba89 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -1,3 +1,7 @@ -import { type RouteConfig, index } from "@react-router/dev/routes"; +import { type RouteConfig, index, route } from "@react-router/dev/routes"; -export default [index("routes/home.tsx")] satisfies RouteConfig; +export default [ + index("routes/home.tsx"), + route("leagues/new", "routes/leagues/new.tsx"), + route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"), +] satisfies RouteConfig; diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx new file mode 100644 index 0000000..38f4786 --- /dev/null +++ b/app/routes/leagues/$leagueId.tsx @@ -0,0 +1,130 @@ +import { Link } from "react-router"; +import { getAuth } from "@clerk/react-router/server"; +import { findLeagueById } from "~/models/league"; +import { findCurrentSeason } from "~/models/season"; +import { findTeamsBySeasonId } from "~/models/team"; +import type { Route } from "./+types/$leagueId"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; + +export async function loader(args: Route.LoaderArgs) { + const { userId } = await getAuth(args); + const { params } = args; + const { leagueId } = params; + + // Fetch league + const league = await findLeagueById(leagueId); + + if (!league) { + throw new Response("League not found", { status: 404 }); + } + + // Fetch current season + const season = await findCurrentSeason(leagueId); + + // Fetch teams for current season + const teams = season ? await findTeamsBySeasonId(season.id) : []; + + return { + league, + season, + teams, + currentUserId: userId, + }; +} + +export default function LeagueHome({ loaderData }: Route.ComponentProps) { + const { league, season, teams, currentUserId } = loaderData; + + return ( +
+
+
+

{league.name}

+ +
+ {season && ( +

+ {season.year} Season • Status:{" "} + + {season.status.replace("_", " ")} + +

+ )} +
+ +
+ + + Teams + + {teams.length} team{teams.length !== 1 ? "s" : ""} in this league + + + +
+ {teams.map((team) => { + const isOwned = team.ownerId === currentUserId; + return ( + + + {team.name} + + {team.ownerId ? ( + isOwned ? ( + + Your Team + + ) : ( + "Owned" + ) + ) : ( + + Available + + )} + + + + ); + })} +
+
+
+ + + + League Info + + +
+

Created

+

+ {new Date(league.createdAt).toLocaleDateString()} +

+
+ {season && ( +
+

Season Status

+

+ {season.status.replace("_", " ")} +

+
+ )} +
+
+
+
+ ); +} diff --git a/app/routes/leagues/new.tsx b/app/routes/leagues/new.tsx new file mode 100644 index 0000000..61abb55 --- /dev/null +++ b/app/routes/leagues/new.tsx @@ -0,0 +1,152 @@ +import { Form, Link, redirect } from "react-router"; +import { getAuth } from "@clerk/react-router/server"; +import { createLeague } from "~/models/league"; +import { createSeason } from "~/models/season"; +import { createManyTeams } from "~/models/team"; +import type { Route } from "./+types/new"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; + +export async function action(args: Route.ActionArgs) { + const { userId } = await getAuth(args); + const { request } = args; + + if (!userId) { + throw new Response("Unauthorized", { status: 401 }); + } + + const formData = await request.formData(); + const name = formData.get("name"); + const teamCount = formData.get("teamCount"); + + // Validation + if (typeof name !== "string" || !name.trim()) { + return { error: "League name is required" }; + } + + if (typeof teamCount !== "string") { + return { error: "Number of teams is required" }; + } + + const teamCountNum = parseInt(teamCount, 10); + if (isNaN(teamCountNum) || teamCountNum < 6 || teamCountNum > 16) { + return { error: "Number of teams must be between 6 and 16" }; + } + + try { + // Create league + const league = await createLeague({ + name: name.trim(), + createdBy: userId, + }); + + // Create first season + const currentYear = new Date().getFullYear(); + const season = await createSeason({ + leagueId: league.id, + year: currentYear, + status: "pre_draft", + }); + + // Create teams with placeholder names + const teams = Array.from({ length: teamCountNum }, (_, i) => ({ + seasonId: season.id, + name: `Team ${i + 1}`, + ownerId: i === 0 ? userId : null, // Assign first team to creator + })); + + await createManyTeams(teams); + + // Redirect to league homepage + return redirect(`/leagues/${league.id}`); + } catch (error) { + console.error("Error creating league:", error); + return { error: "Failed to create league. Please try again." }; + } +} + +export default function NewLeague({ actionData }: Route.ComponentProps) { + return ( +
+ + + Start a New League + + Create your fantasy league and invite your friends to compete. + + + +
+
+ + +
+ +
+ + +

+ Choose between 6 and 16 teams +

+
+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/app/welcome/welcome.tsx b/app/welcome/welcome.tsx index 8f27269..970cc1e 100644 --- a/app/welcome/welcome.tsx +++ b/app/welcome/welcome.tsx @@ -1,4 +1,5 @@ -import { Form, useNavigation } from "react-router"; +import { Form, Link, useNavigation } from "react-router"; +import { Button } from "~/components/ui/button"; import logoDark from "./logo-dark.svg"; import logoLight from "./logo-light.svg"; @@ -36,6 +37,11 @@ export function Welcome({
+
+ +