261 lines
8.3 KiB
TypeScript
261 lines
8.3 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|