brackt/app/routes/leagues/$leagueId.settings.tsx

781 lines
28 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
import { Form, Link, redirect, useNavigation } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { isCommissioner } from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
import { findTeamsBySeasonId, createManyTeams, deleteTeam } from "~/models/team";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findAllSportsSeasons } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
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 { Calendar } from "~/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
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 with sports
const season = await findCurrentSeasonWithSports(leagueId);
const teams = season ? await findTeamsBySeasonId(season.id) : [];
const allSportsSeasons = await findAllSportsSeasons();
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
// Count teams with owners
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
return {
league,
season,
teams,
teamCount: teams.length,
teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
draftSlots,
};
}
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");
// Get current season to check status
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
return { error: "No active season found" };
}
if (intent === "update-sports") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify sports after draft has started" };
}
const selectedSports = formData.getAll("sportsSeasons");
const currentSportIds = new Set(
season.seasonSports?.map((s: any) => s.sportsSeason.id) || []
);
const newSportIds = new Set(selectedSports as string[]);
// Remove sports that are no longer selected
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
// Add newly selected sports
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId as string,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
return { success: true };
}
if (intent === "set-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = formData.getAll("teamOrder") as string[];
// Validate that all teams are included
if (teamIds.length !== teams.length) {
return { error: "All teams must be included in the draft order" };
}
// Validate that all team IDs are valid
const validTeamIds = new Set(teams.map(t => t.id));
for (const teamId of teamIds) {
if (!validTeamIds.has(teamId)) {
return { error: "Invalid team ID in draft order" };
}
}
await setDraftOrder(season.id, teamIds);
return { success: true, message: "Draft order updated successfully" };
}
if (intent === "randomize-draft-order") {
if (season.status !== "pre_draft") {
return { error: "Cannot modify draft order after draft has started" };
}
const teams = await findTeamsBySeasonId(season.id);
const teamIds = teams.map(t => t.id);
await randomizeDraftOrder(season.id, teamIds);
return { success: true, message: "Draft order randomized successfully" };
}
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");
const teamCount = formData.get("teamCount");
const draftDateTime = formData.get("draftDateTime");
const draftRounds = formData.get("draftRounds");
// 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(),
});
// Update season settings
const seasonUpdates: any = {};
// Handle draft rounds
if (typeof draftRounds === "string") {
const draftRoundsNum = parseInt(draftRounds, 10);
if (!isNaN(draftRoundsNum)) {
const sportsCount = season.seasonSports?.length || 0;
if (draftRoundsNum < sportsCount) {
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
}
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
return { error: "Draft rounds must be between 1 and 50" };
}
seasonUpdates.draftRounds = draftRoundsNum;
}
}
// Handle draft date/time
if (typeof draftDateTime === "string") {
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
}
// Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
}
// Handle team count changes if provided
if (typeof teamCount === "string") {
const newTeamCount = parseInt(teamCount, 10);
if (!isNaN(newTeamCount)) {
const teams = await findTeamsBySeasonId(season.id);
const currentTeamCount = teams.length;
const teamsWithOwners = teams.filter(t => t.ownerId !== null).length;
// Validate team count
if (newTeamCount < 6 || newTeamCount > 16) {
return { error: "Number of teams must be between 6 and 16" };
}
if (newTeamCount < teamsWithOwners) {
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
}
// Add or remove teams as needed
if (newTeamCount > currentTeamCount) {
// Add new teams
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: `Team ${currentTeamCount + i + 1}`,
ownerId: null,
})
);
await createManyTeams(teamsToAdd);
} else if (newTeamCount < currentTeamCount) {
// Remove teams without owners (from the end)
const teamsToRemove = teams
.filter(t => t.ownerId === null)
.slice(-(currentTeamCount - newTeamCount));
for (const team of teamsToRemove) {
await deleteTeam(team.id);
}
}
}
}
return redirect(`/leagues/${leagueId}?updated=true`);
} 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, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedSports, setSelectedSports] = useState<Set<string>>(
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
);
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id)
);
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
const [draftDate, setDraftDate] = useState<Date | undefined>(
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
);
const [draftTime, setDraftTime] = useState<string>(
season?.draftDateTime
? format(new Date(season.draftDateTime), "HH:mm")
: ""
);
// Update draft order when loader data changes (after randomization)
useEffect(() => {
const newOrder = draftSlots.length > 0
? draftSlots.map((slot: any) => slot.teamId)
: teams.map((team: any) => team.id);
setDraftOrderTeams(newOrder);
}, [draftSlots, teams]);
const canEditSports = season && season.status === "pre_draft";
const canEditTeamCount = season && season.status === "pre_draft";
const canEditDraftOrder = season && season.status === "pre_draft";
const canEditDraftRounds = season && season.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners);
// Calculate flex spots and minimum rounds
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
const handleSportToggle = (sportId: string) => {
const newSelected = new Set(selectedSports);
if (newSelected.has(sportId)) {
newSelected.delete(sportId);
} else {
newSelected.add(sportId);
}
setSelectedSports(newSelected);
};
const handleDelete = () => {
setIsDeleteDialogOpen(false);
// The form submission will handle the actual deletion
};
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
const newOrder = [...draftOrderTeams];
const [movedTeam] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedTeam);
setDraftOrderTeams(newOrder);
};
const getTeamById = (teamId: string) => {
return teams.find((t: any) => t.id === teamId);
};
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>
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="update" />
{/* General Settings */}
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>
Update your league's basic information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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={!canEditTeamCount}
>
<SelectTrigger id="teamCount">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
<SelectItem
key={num}
value={num.toString()}
disabled={num < minTeamCount}
>
{num} Teams
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{canEditTeamCount
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
: "Team count cannot be changed after draft has started"}
</p>
</div>
</CardContent>
</Card>
{/* Draft Rounds */}
<Card>
<CardHeader>
<CardTitle>Draft Rounds</CardTitle>
<CardDescription>
{canEditDraftRounds
? "Set the number of draft rounds for your league"
: "Draft rounds cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
<Select
name="draftRounds"
value={draftRounds.toString()}
onValueChange={(value) => setDraftRounds(parseInt(value))}
disabled={!canEditDraftRounds}
required
>
<SelectTrigger id="draftRounds">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Array.from({ length: 50 }, (_, i) => i + 1)
.filter(num => num >= minRounds)
.map((num) => (
<SelectItem
key={num}
value={num.toString()}
>
{num} Round{num !== 1 ? 's' : ''}
{num === recommendedRounds && ' (recommended)'}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
Minimum: {minRounds} (number of sports selected)
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
</p>
{sportsCount > 0 && draftRounds < sportsCount && (
<p className="text-sm font-medium text-destructive">
You need at least {sportsCount} rounds for the {sportsCount} sports selected
</p>
)}
{sportsCount > 0 && draftRounds >= sportsCount && (
<p className="text-sm font-medium text-primary">
Flex Spots: {flexSpots}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="draftDate">Draft Date & Time</Label>
<div className="grid gap-2 sm:grid-cols-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"justify-start text-left font-normal",
!draftDate && "text-muted-foreground"
)}
disabled={!canEditDraftRounds}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={draftDate}
onSelect={setDraftDate}
initialFocus
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={draftTime}
onChange={(e) => setDraftTime(e.target.value)}
placeholder="Select time"
disabled={!canEditDraftRounds}
/>
</div>
{draftDate && draftTime && (
<input
type="hidden"
name="draftDateTime"
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
/>
)}
{!draftDate && !draftTime && (
<input type="hidden" name="draftDateTime" value="" />
)}
<p className="text-sm text-muted-foreground">
{canEditDraftRounds
? "You must set a draft date and time before starting the draft"
: "Draft date cannot be changed after draft has started"}
</p>
</div>
</CardContent>
</Card>
{/* Sports Seasons */}
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{canEditSports
? "Select the sports seasons that teams can draft from"
: "Sports cannot be modified after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">
Sports Seasons ({selectedSports.size} selected)
</Label>
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
{allSportsSeasons.length > 0 ? (
allSportsSeasons.map((season) => (
<div key={season.id} className="flex items-center space-x-2">
<Checkbox
id={season.id}
name="sportsSeasons"
value={season.id}
checked={selectedSports.has(season.id)}
onCheckedChange={() => handleSportToggle(season.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={season.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
<Badge variant="outline" className="ml-2 text-xs">
{season.scoringType.replace("_", " ")}
</Badge>
</Label>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No sports seasons available
</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
Settings updated successfully!
</div>
)}
<Button type="submit" size="lg" className="w-full">
Save All Settings
</Button>
</Form>
<div className="space-y-6">
{/* Draft Order Management */}
<Card>
<CardHeader>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{canEditDraftOrder
? "Set the draft order for your league. Drag teams to reorder or randomize."
: "Draft order cannot be changed after the draft has started"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{draftSlots.length === 0 && canEditDraftOrder && (
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
<p className="text-sm text-muted-foreground">
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
</p>
</div>
)}
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="set-draft-order" />
<div className="space-y-2">
<div className="border rounded-md divide-y">
{draftOrderTeams.map((teamId, index) => {
const team = getTeamById(teamId);
if (!team) return null;
return (
<div key={teamId} className="flex items-center gap-3 p-3">
<input type="hidden" name="teamOrder" value={teamId} />
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
{index + 1}
</div>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
</div>
{canEditDraftOrder && (
<div className="flex gap-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
disabled={index === 0}
>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
disabled={index === draftOrderTeams.length - 1}
>
</Button>
</div>
)}
</div>
);
})}
</div>
</div>
{canEditDraftOrder && (
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Save Draft Order
</Button>
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
</Form>
{canEditDraftOrder && (
<Form method="post">
<input type="hidden" name="intent" value="randomize-draft-order" />
<Button
type="submit"
variant="outline"
className="w-full"
disabled={navigation.state === "submitting"}
>
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
? "Randomizing..."
: "🎲 Randomize Draft Order"}
</Button>
</Form>
)}
</div>
</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>
);
}