feat: add fantasy league schema and new league routes

This commit is contained in:
Chris Parsons 2025-10-11 00:07:39 -07:00
parent 6d998a5f85
commit 5c957249ad
19 changed files with 1459 additions and 4 deletions

View file

@ -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<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "app/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "app/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"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",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -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<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -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<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

4
app/models/index.ts Normal file
View file

@ -0,0 +1,4 @@
// Re-export all model functions and types
export * from "./league";
export * from "./season";
export * from "./team";

60
app/models/league.ts Normal file
View file

@ -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<League> {
const db = database();
const [league] = await db
.insert(schema.leagues)
.values(data)
.returning();
return league;
}
export async function findLeagueById(id: string): Promise<League | undefined> {
const db = database();
return await db.query.leagues.findFirst({
where: eq(schema.leagues.id, id),
});
}
export async function findLeaguesByCreator(userId: string): Promise<League[]> {
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<NewLeague>
): Promise<League> {
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<void> {
const db = database();
await db.delete(schema.leagues).where(eq(schema.leagues.id, id));
}
export async function listLeagues(options?: {
limit?: number;
offset?: number;
}): Promise<League[]> {
const db = database();
return await db.query.leagues.findMany({
limit: options?.limit,
offset: options?.offset,
orderBy: (leagues, { desc }) => [desc(leagues.createdAt)],
});
}

79
app/models/season.ts Normal file
View file

@ -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<Season> {
const db = database();
const [season] = await db
.insert(schema.seasons)
.values(data)
.returning();
return season;
}
export async function findSeasonById(id: string): Promise<Season | undefined> {
const db = database();
return await db.query.seasons.findFirst({
where: eq(schema.seasons.id, id),
});
}
export async function findSeasonsByLeagueId(leagueId: string): Promise<Season[]> {
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<Season | undefined> {
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<Season | undefined> {
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<NewSeason>
): Promise<Season> {
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<Season> {
return await updateSeason(id, { status });
}
export async function deleteSeason(id: string): Promise<void> {
const db = database();
await db.delete(schema.seasons).where(eq(schema.seasons.id, id));
}

84
app/models/team.ts Normal file
View file

@ -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<Team> {
const db = database();
const [team] = await db.insert(schema.teams).values(data).returning();
return team;
}
export async function createManyTeams(teams: NewTeam[]): Promise<Team[]> {
const db = database();
return await db.insert(schema.teams).values(teams).returning();
}
export async function findTeamById(id: string): Promise<Team | undefined> {
const db = database();
return await db.query.teams.findFirst({
where: eq(schema.teams.id, id),
});
}
export async function findTeamsBySeasonId(seasonId: string): Promise<Team[]> {
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<Team[]> {
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<Team[]> {
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<NewTeam>
): Promise<Team> {
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<Team> {
return await updateTeam(id, { ownerId });
}
export async function removeTeamOwner(id: string): Promise<Team> {
return await updateTeam(id, { ownerId: null });
}
export async function renameTeam(id: string, name: string): Promise<Team> {
return await updateTeam(id, { name });
}
export async function deleteTeam(id: string): Promise<void> {
const db = database();
await db.delete(schema.teams).where(eq(schema.teams.id, id));
}

View file

@ -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;

View file

@ -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 (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">{league.name}</h1>
<Button variant="outline" asChild>
<Link to="/">Back to Home</Link>
</Button>
</div>
{season && (
<p className="text-muted-foreground">
{season.year} Season Status:{" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card className="md:col-span-2 lg:col-span-3">
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => {
const isOwned = team.ownerId === currentUserId;
return (
<Card
key={team.id}
className={isOwned ? "border-primary" : ""}
>
<CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription>
{team.ownerId ? (
isOwned ? (
<span className="text-primary font-medium">
Your Team
</span>
) : (
"Owned"
)
) : (
<span className="text-muted-foreground">
Available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{new Date(league.createdAt).toLocaleDateString()}
</p>
</div>
{season && (
<div>
<p className="text-sm text-muted-foreground">Season Status</p>
<p className="font-medium capitalize">
{season.status.replace("_", " ")}
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

152
app/routes/leagues/new.tsx Normal file
View file

@ -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 (
<div className="container max-w-2xl mx-auto py-8 px-4">
<Card>
<CardHeader>
<CardTitle className="text-3xl">Start a New League</CardTitle>
<CardDescription>
Create your fantasy league and invite your friends to compete.
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">League Name</Label>
<Input
id="name"
name="name"
type="text"
placeholder="Enter league name"
required
minLength={3}
maxLength={50}
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamCount">Number of Teams</Label>
<Select name="teamCount" defaultValue="12" required>
<SelectTrigger id="teamCount">
<SelectValue placeholder="Select number of teams" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">6 Teams</SelectItem>
<SelectItem value="7">7 Teams</SelectItem>
<SelectItem value="8">8 Teams</SelectItem>
<SelectItem value="9">9 Teams</SelectItem>
<SelectItem value="10">10 Teams</SelectItem>
<SelectItem value="11">11 Teams</SelectItem>
<SelectItem value="12">12 Teams</SelectItem>
<SelectItem value="13">13 Teams</SelectItem>
<SelectItem value="14">14 Teams</SelectItem>
<SelectItem value="15">15 Teams</SelectItem>
<SelectItem value="16">16 Teams</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Choose between 6 and 16 teams
</p>
</div>
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
<div className="flex gap-4">
<Button type="submit" className="flex-1">
Create League
</Button>
<Button type="button" variant="outline" asChild>
<Link to="/">Cancel</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</div>
);
}

View file

@ -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 logoDark from "./logo-dark.svg";
import logoLight from "./logo-light.svg"; import logoLight from "./logo-light.svg";
@ -36,6 +37,11 @@ export function Welcome({
</div> </div>
</header> </header>
<div className="max-w-[300px] w-full space-y-6 px-4"> <div className="max-w-[300px] w-full space-y-6 px-4">
<div className="flex justify-center">
<Button size="lg" asChild className="w-full">
<Link to="/leagues/new">Start a League</Link>
</Button>
</div>
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4"> <nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center"> <p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
What&apos;s next? What&apos;s next?

View file

@ -1,7 +1,45 @@
import { integer, pgTable, varchar } from "drizzle-orm/pg-core"; import { integer, pgTable, varchar, uuid, timestamp, pgEnum } from "drizzle-orm/pg-core";
export const guestBook = pgTable("guestBook", { export const guestBook = pgTable("guestBook", {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(), name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(), email: varchar({ length: 255 }).notNull().unique(),
}); });
// Fantasy League Tables
export const seasonStatusEnum = pgEnum("season_status", [
"pre_draft",
"draft",
"active",
"completed",
]);
export const leagues = pgTable("leagues", {
id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
createdBy: varchar("created_by", { length: 255 }).notNull(), // Clerk user ID
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const seasons = pgTable("seasons", {
id: uuid("id").primaryKey().defaultRandom(),
leagueId: uuid("league_id")
.notNull()
.references(() => leagues.id, { onDelete: "cascade" }),
year: integer("year").notNull(),
status: seasonStatusEnum("status").notNull().default("pre_draft"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const teams = pgTable("teams", {
id: uuid("id").primaryKey().defaultRandom(),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
ownerId: varchar("owner_id", { length: 255 }), // Clerk user ID, nullable
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

View file

@ -0,0 +1,38 @@
CREATE TYPE "public"."season_status" AS ENUM('pre_draft', 'draft', 'active', 'completed');--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "leagues" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" varchar(255) NOT NULL,
"created_by" varchar(255) NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "seasons" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"league_id" uuid NOT NULL,
"year" integer NOT NULL,
"status" "season_status" DEFAULT 'pre_draft' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "teams" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"season_id" uuid NOT NULL,
"name" varchar(255) NOT NULL,
"owner_id" varchar(255),
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "seasons" ADD CONSTRAINT "seasons_league_id_leagues_id_fk" FOREIGN KEY ("league_id") REFERENCES "public"."leagues"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "teams" ADD CONSTRAINT "teams_season_id_seasons_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."seasons"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,260 @@
{
"id": "dfca1306-7467-4d4e-8b9f-546611ed8b00",
"prevId": "6bf145c1-851c-4a50-a085-9306f05abb25",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.guestBook": {
"name": "guestBook",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "guestBook_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"guestBook_email_unique": {
"name": "guestBook_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leagues": {
"name": "leagues",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_by": {
"name": "created_by",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.seasons": {
"name": "seasons",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"league_id": {
"name": "league_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"year": {
"name": "year",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "season_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pre_draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"seasons_league_id_leagues_id_fk": {
"name": "seasons_league_id_leagues_id_fk",
"tableFrom": "seasons",
"tableTo": "leagues",
"columnsFrom": [
"league_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.teams": {
"name": "teams",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"season_id": {
"name": "season_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"owner_id": {
"name": "owner_id",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"teams_season_id_seasons_id_fk": {
"name": "teams_season_id_seasons_id_fk",
"tableFrom": "teams",
"tableTo": "seasons",
"columnsFrom": [
"season_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.season_status": {
"name": "season_status",
"schema": "public",
"values": [
"pre_draft",
"draft",
"active",
"completed"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1732076135211, "when": 1732076135211,
"tag": "0000_short_donald_blake", "tag": "0000_short_donald_blake",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1760165302772,
"tag": "0001_serious_la_nuit",
"breakpoints": true
} }
] ]
} }

210
package-lock.json generated
View file

@ -8,7 +8,10 @@
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@react-router/express": "^7.7.1", "@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -1501,6 +1504,44 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -1661,12 +1702,41 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": { "node_modules/@radix-ui/react-collection": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
@ -1859,6 +1929,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-label": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-navigation-menu": { "node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14", "version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
@ -1895,6 +1988,38 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popper": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-rect": "1.1.1",
"@radix-ui/react-use-size": "1.1.1",
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": { "node_modules/@radix-ui/react-portal": {
"version": "1.1.9", "version": "1.1.9",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
@ -1966,6 +2091,49 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@ -2084,6 +2252,42 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
"license": "MIT",
"dependencies": {
"@radix-ui/rect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": { "node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
@ -2107,6 +2311,12 @@
} }
} }
}, },
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@react-router/dev": { "node_modules/@react-router/dev": {
"version": "7.9.4", "version": "7.9.4",
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.4.tgz", "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.9.4.tgz",

View file

@ -13,7 +13,10 @@
"dependencies": { "dependencies": {
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@react-router/express": "^7.7.1", "@react-router/express": "^7.7.1",
"@react-router/node": "^7.7.1", "@react-router/node": "^7.7.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",