feat: add commissioner model and league management features
This commit is contained in:
parent
5c957249ad
commit
9dc033b2a0
16 changed files with 979 additions and 4 deletions
157
app/components/ui/alert-dialog.tsx
Normal file
157
app/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "app/lib/utils"
|
||||
import { buttonVariants } from "app/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
21
app/components/ui/sonner.tsx
Normal file
21
app/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
71
app/models/commissioner.ts
Normal file
71
app/models/commissioner.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export type Commissioner = typeof schema.commissioners.$inferSelect;
|
||||
export type NewCommissioner = typeof schema.commissioners.$inferInsert;
|
||||
|
||||
export async function createCommissioner(
|
||||
data: NewCommissioner
|
||||
): Promise<Commissioner> {
|
||||
const db = database();
|
||||
const [commissioner] = await db
|
||||
.insert(schema.commissioners)
|
||||
.values(data)
|
||||
.returning();
|
||||
return commissioner;
|
||||
}
|
||||
|
||||
export async function findCommissionersByLeagueId(
|
||||
leagueId: string
|
||||
): Promise<Commissioner[]> {
|
||||
const db = database();
|
||||
return await db.query.commissioners.findMany({
|
||||
where: eq(schema.commissioners.leagueId, leagueId),
|
||||
orderBy: (commissioners, { asc }) => [asc(commissioners.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
export async function findCommissionersByUserId(
|
||||
userId: string
|
||||
): Promise<Commissioner[]> {
|
||||
const db = database();
|
||||
return await db.query.commissioners.findMany({
|
||||
where: eq(schema.commissioners.userId, userId),
|
||||
orderBy: (commissioners, { desc }) => [desc(commissioners.createdAt)],
|
||||
});
|
||||
}
|
||||
|
||||
export async function isCommissioner(
|
||||
leagueId: string,
|
||||
userId: string
|
||||
): Promise<boolean> {
|
||||
const db = database();
|
||||
const commissioner = await db.query.commissioners.findFirst({
|
||||
where: and(
|
||||
eq(schema.commissioners.leagueId, leagueId),
|
||||
eq(schema.commissioners.userId, userId)
|
||||
),
|
||||
});
|
||||
return !!commissioner;
|
||||
}
|
||||
|
||||
export async function deleteCommissioner(id: string): Promise<void> {
|
||||
const db = database();
|
||||
await db.delete(schema.commissioners).where(eq(schema.commissioners.id, id));
|
||||
}
|
||||
|
||||
export async function removeCommissionerByLeagueAndUser(
|
||||
leagueId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.commissioners)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.commissioners.leagueId, leagueId),
|
||||
eq(schema.commissioners.userId, userId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
export * from "./league";
|
||||
export * from "./season";
|
||||
export * from "./team";
|
||||
export * from "./commissioner";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import "./app.css";
|
|||
import { rootAuthLoader } from "@clerk/react-router/server";
|
||||
import { Navbar } from "~/components/navbar";
|
||||
import { ClerkProvider } from "@clerk/react-router";
|
||||
import { Toaster } from "~/components/ui/sonner";
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
return rootAuthLoader(args);
|
||||
|
|
@ -55,6 +56,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
|||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Toaster />
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ export default [
|
|||
index("routes/home.tsx"),
|
||||
route("leagues/new", "routes/leagues/new.tsx"),
|
||||
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
||||
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useEffect } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { Route } from "./+types/home";
|
||||
import { Welcome } from "../welcome/welcome";
|
||||
|
|
@ -50,6 +53,16 @@ export async function loader({ context }: Route.LoaderArgs) {
|
|||
}
|
||||
|
||||
export default function Home({ actionData, loaderData }: Route.ComponentProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("deleted") === "true") {
|
||||
toast.success("League deleted successfully");
|
||||
// Remove the query parameter
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Welcome
|
||||
guestBook={loaderData.guestBook}
|
||||
|
|
|
|||
260
app/routes/leagues/$leagueId.settings.tsx
Normal file
260
app/routes/leagues/$leagueId.settings.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from "react";
|
||||
import { Form, Link, redirect } from "react-router";
|
||||
import { getAuth } from "@clerk/react-router/server";
|
||||
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
||||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { findCurrentSeason } from "~/models/season";
|
||||
import { findTeamsBySeasonId } from "~/models/team";
|
||||
import type { Route } from "./+types/$leagueId.settings";
|
||||
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";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
|
||||
export async function loader(args: Route.LoaderArgs) {
|
||||
const { userId } = await getAuth(args);
|
||||
const { params } = args;
|
||||
const { leagueId } = params;
|
||||
|
||||
if (!userId) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch league
|
||||
const league = await findLeagueById(leagueId);
|
||||
|
||||
if (!league) {
|
||||
throw new Response("League not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user is authorized (creator or commissioner)
|
||||
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
||||
const isCreator = league.createdBy === userId;
|
||||
|
||||
if (!isCreator && !userIsCommissioner) {
|
||||
throw new Response("Forbidden - You must be a commissioner to access settings", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Get current season to count teams
|
||||
const season = await findCurrentSeason(leagueId);
|
||||
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
||||
|
||||
return {
|
||||
league,
|
||||
teamCount: teams.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(args: Route.ActionArgs) {
|
||||
const { userId } = await getAuth(args);
|
||||
const { params, request } = args;
|
||||
const { leagueId } = params;
|
||||
|
||||
if (!userId) {
|
||||
throw new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Verify authorization
|
||||
const league = await findLeagueById(leagueId);
|
||||
if (!league) {
|
||||
throw new Response("League not found", { status: 404 });
|
||||
}
|
||||
|
||||
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
||||
const isCreator = league.createdBy === userId;
|
||||
|
||||
if (!isCreator && !userIsCommissioner) {
|
||||
throw new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === "delete") {
|
||||
// Delete the league
|
||||
await deleteLeague(leagueId);
|
||||
|
||||
// Redirect to home with success message
|
||||
return redirect("/?deleted=true");
|
||||
}
|
||||
|
||||
if (intent === "update") {
|
||||
const name = formData.get("name");
|
||||
|
||||
// Validation
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
return { error: "League name is required" };
|
||||
}
|
||||
|
||||
if (name.trim().length < 3 || name.trim().length > 50) {
|
||||
return { error: "League name must be between 3 and 50 characters" };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateLeague(leagueId, {
|
||||
name: name.trim(),
|
||||
});
|
||||
|
||||
return { success: "League updated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating league:", error);
|
||||
return { error: "Failed to update league. Please try again." };
|
||||
}
|
||||
}
|
||||
|
||||
return { error: "Invalid action" };
|
||||
}
|
||||
|
||||
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const { league, teamCount } = loaderData;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
// The form submission will handle the actual deletion
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container max-w-2xl 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 Settings</h1>
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/leagues/${league.id}`}>Back to League</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{league.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Update League Form */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>General Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Update your league's basic information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-4">
|
||||
<input type="hidden" name="intent" value="update" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">League Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
defaultValue={league.name}
|
||||
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={teamCount.toString()} disabled>
|
||||
<SelectTrigger id="teamCount">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num} Teams
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Team count cannot be changed after league creation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{actionData?.success && (
|
||||
<div className="bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300 px-4 py-3 rounded-md text-sm">
|
||||
{actionData.success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData?.error && (
|
||||
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
||||
{actionData.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
Save Changes
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
Irreversible and destructive actions
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-full">
|
||||
Delete League
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete <strong>{league.name}</strong> and all
|
||||
associated data including seasons, teams, and commissioners. This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Form method="post" onSubmit={handleDelete}>
|
||||
<input type="hidden" name="intent" value="delete" />
|
||||
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
||||
Delete League
|
||||
</AlertDialogAction>
|
||||
</Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { getAuth } from "@clerk/react-router/server";
|
|||
import { findLeagueById } from "~/models/league";
|
||||
import { findCurrentSeason } from "~/models/season";
|
||||
import { findTeamsBySeasonId } from "~/models/team";
|
||||
import { findCommissionersByLeagueId } from "~/models/commissioner";
|
||||
import type { Route } from "./+types/$leagueId";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -31,26 +32,41 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
// Fetch teams for current season
|
||||
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
||||
|
||||
// Fetch commissioners
|
||||
const commissioners = await findCommissionersByLeagueId(leagueId);
|
||||
|
||||
// Check if current user is a commissioner
|
||||
const isUserCommissioner = commissioners.some(c => c.userId === userId);
|
||||
|
||||
return {
|
||||
league,
|
||||
season,
|
||||
teams,
|
||||
commissioners,
|
||||
currentUserId: userId,
|
||||
isUserCommissioner,
|
||||
};
|
||||
}
|
||||
|
||||
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||
const { league, season, teams, currentUserId } = loaderData;
|
||||
const { league, season, teams, commissioners, currentUserId, isUserCommissioner } = 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>
|
||||
<div className="flex gap-2">
|
||||
{isUserCommissioner && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/leagues/${league.id}/settings`}>Settings</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/">Back to Home</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{season && (
|
||||
<p className="text-muted-foreground">
|
||||
{season.year} Season • Status:{" "}
|
||||
|
|
@ -124,6 +140,37 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Commissioners</CardTitle>
|
||||
<CardDescription>
|
||||
{commissioners.length} commissioner{commissioners.length !== 1 ? "s" : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{commissioners.map((commissioner, index) => (
|
||||
<div
|
||||
key={commissioner.id}
|
||||
className="flex items-center justify-between py-2 border-b last:border-0"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
Commissioner {index + 1}
|
||||
{commissioner.userId === currentUserId && (
|
||||
<span className="ml-2 text-xs text-primary">(You)</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Since {new Date(commissioner.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getAuth } from "@clerk/react-router/server";
|
|||
import { createLeague } from "~/models/league";
|
||||
import { createSeason } from "~/models/season";
|
||||
import { createManyTeams } from "~/models/team";
|
||||
import { createCommissioner } from "~/models/commissioner";
|
||||
import type { Route } from "./+types/new";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
|
|
@ -55,6 +56,12 @@ export async function action(args: Route.ActionArgs) {
|
|||
createdBy: userId,
|
||||
});
|
||||
|
||||
// Make creator a commissioner
|
||||
await createCommissioner({
|
||||
leagueId: league.id,
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
// Create first season
|
||||
const currentYear = new Date().getFullYear();
|
||||
const season = await createSeason({
|
||||
|
|
|
|||
|
|
@ -43,3 +43,12 @@ export const teams = pgTable("teams", {
|
|||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const commissioners = pgTable("commissioners", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
leagueId: uuid("league_id")
|
||||
.notNull()
|
||||
.references(() => leagues.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 }).notNull(), // Clerk user ID
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
12
drizzle/0002_tired_iron_man.sql
Normal file
12
drizzle/0002_tired_iron_man.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
CREATE TABLE IF NOT EXISTS "commissioners" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"league_id" uuid NOT NULL,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "commissioners" ADD CONSTRAINT "commissioners_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 $$;
|
||||
313
drizzle/meta/0002_snapshot.json
Normal file
313
drizzle/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
{
|
||||
"id": "34297d0b-9216-46c7-9d1d-5b81f1402786",
|
||||
"prevId": "dfca1306-7467-4d4e-8b9f-546611ed8b00",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.commissioners": {
|
||||
"name": "commissioners",
|
||||
"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
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"commissioners_league_id_leagues_id_fk": {
|
||||
"name": "commissioners_league_id_leagues_id_fk",
|
||||
"tableFrom": "commissioners",
|
||||
"tableTo": "leagues",
|
||||
"columnsFrom": [
|
||||
"league_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1760165302772,
|
||||
"tag": "0001_serious_la_nuit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1760166775427,
|
||||
"tag": "0002_tired_iron_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
51
package-lock.json
generated
51
package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"name": "brackt.com",
|
||||
"dependencies": {
|
||||
"@clerk/react-router": "^2.1.0",
|
||||
"@radix-ui/react-alert-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",
|
||||
|
|
@ -22,10 +23,12 @@
|
|||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.545.0",
|
||||
"morgan": "^1.10.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1714,6 +1717,34 @@
|
|||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
|
||||
"integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.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-arrow": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||
|
|
@ -5034,6 +5065,16 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.23",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||
|
|
@ -5900,6 +5941,16 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@clerk/react-router": "^2.1.0",
|
||||
"@radix-ui/react-alert-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",
|
||||
|
|
@ -27,10 +28,12 @@
|
|||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.545.0",
|
||||
"morgan": "^1.10.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.7.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue