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 "./league";
|
||||||
export * from "./season";
|
export * from "./season";
|
||||||
export * from "./team";
|
export * from "./team";
|
||||||
|
export * from "./commissioner";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import "./app.css";
|
||||||
import { rootAuthLoader } from "@clerk/react-router/server";
|
import { rootAuthLoader } from "@clerk/react-router/server";
|
||||||
import { Navbar } from "~/components/navbar";
|
import { Navbar } from "~/components/navbar";
|
||||||
import { ClerkProvider } from "@clerk/react-router";
|
import { ClerkProvider } from "@clerk/react-router";
|
||||||
|
import { Toaster } from "~/components/ui/sonner";
|
||||||
|
|
||||||
export async function loader(args: Route.LoaderArgs) {
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
return rootAuthLoader(args);
|
return rootAuthLoader(args);
|
||||||
|
|
@ -55,6 +56,7 @@ export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<Toaster />
|
||||||
</ClerkProvider>
|
</ClerkProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ export default [
|
||||||
index("routes/home.tsx"),
|
index("routes/home.tsx"),
|
||||||
route("leagues/new", "routes/leagues/new.tsx"),
|
route("leagues/new", "routes/leagues/new.tsx"),
|
||||||
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
route("leagues/:leagueId", "routes/leagues/$leagueId.tsx"),
|
||||||
|
route("leagues/:leagueId/settings", "routes/leagues/$leagueId.settings.tsx"),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from "./+types/home";
|
||||||
import { Welcome } from "../welcome/welcome";
|
import { Welcome } from "../welcome/welcome";
|
||||||
|
|
@ -50,6 +53,16 @@ export async function loader({ context }: Route.LoaderArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ actionData, loaderData }: Route.ComponentProps) {
|
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 (
|
return (
|
||||||
<Welcome
|
<Welcome
|
||||||
guestBook={loaderData.guestBook}
|
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 { findLeagueById } from "~/models/league";
|
||||||
import { findCurrentSeason } from "~/models/season";
|
import { findCurrentSeason } from "~/models/season";
|
||||||
import { findTeamsBySeasonId } from "~/models/team";
|
import { findTeamsBySeasonId } from "~/models/team";
|
||||||
|
import { findCommissionersByLeagueId } from "~/models/commissioner";
|
||||||
import type { Route } from "./+types/$leagueId";
|
import type { Route } from "./+types/$leagueId";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -31,26 +32,41 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
// Fetch teams for current season
|
// Fetch teams for current season
|
||||||
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
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 {
|
return {
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
teams,
|
teams,
|
||||||
|
commissioners,
|
||||||
currentUserId: userId,
|
currentUserId: userId,
|
||||||
|
isUserCommissioner,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
const { league, season, teams, currentUserId } = loaderData;
|
const { league, season, teams, commissioners, currentUserId, isUserCommissioner } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h1 className="text-4xl font-bold">{league.name}</h1>
|
<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>
|
<Button variant="outline" asChild>
|
||||||
<Link to="/">Back to Home</Link>
|
<Link to="/">Back to Home</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{season && (
|
{season && (
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{season.year} Season • Status:{" "}
|
{season.year} Season • Status:{" "}
|
||||||
|
|
@ -124,6 +140,37 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { createLeague } from "~/models/league";
|
import { createLeague } from "~/models/league";
|
||||||
import { createSeason } from "~/models/season";
|
import { createSeason } from "~/models/season";
|
||||||
import { createManyTeams } from "~/models/team";
|
import { createManyTeams } from "~/models/team";
|
||||||
|
import { createCommissioner } from "~/models/commissioner";
|
||||||
import type { Route } from "./+types/new";
|
import type { Route } from "./+types/new";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -55,6 +56,12 @@ export async function action(args: Route.ActionArgs) {
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Make creator a commissioner
|
||||||
|
await createCommissioner({
|
||||||
|
leagueId: league.id,
|
||||||
|
userId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
// Create first season
|
// Create first season
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const season = await createSeason({
|
const season = await createSeason({
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,12 @@ export const teams = pgTable("teams", {
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_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,
|
"when": 1760165302772,
|
||||||
"tag": "0001_serious_la_nuit",
|
"tag": "0001_serious_la_nuit",
|
||||||
"breakpoints": true
|
"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",
|
"name": "brackt.com",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
|
@ -22,10 +23,12 @@
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1714,6 +1717,34 @@
|
||||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
|
@ -5034,6 +5065,16 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.23",
|
"version": "2.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz",
|
||||||
|
|
@ -5900,6 +5941,16 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
|
@ -27,10 +28,12 @@
|
||||||
"isbot": "^5.1.27",
|
"isbot": "^5.1.27",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-router": "^7.7.1",
|
"react-router": "^7.7.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue