brackt/app/routes/leagues/$leagueId.settings.tsx
Chris Parsons 5b03b22267
Add Brackt as a league-private meta-sport (#200) (#377)
Commissioners can add Brackt to any league. Each team drafts one manager
from their own league; at season end, that manager's final overall fantasy
ranking earns placement points. Resolution fires automatically when all
other sports finalize.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 20:31:44 -07:00

1808 lines
71 KiB
TypeScript

import { useState, useEffect } from "react";
import { Form, Link, redirect, useNavigation } from "react-router";
import { auth } from "~/lib/auth.server";
import { format } from "date-fns";
import { CalendarIcon } from "lucide-react";
import { logger } from "~/lib/logger";
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
import { sendStandingsUpdateNotification } from "~/services/discord";
import {
isCommissioner,
hasCommissionerRecord,
findCommissionersByLeagueId,
createCommissioner,
countCommissionersByLeagueId,
removeCommissionerByLeagueAndUser,
} from "~/models/commissioner";
import { findCurrentSeasonWithSports, updateSeason, type NewSeason } from "~/models/season";
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam, findTeamById, renameTeam } from "~/models/team";
import { findAllUsers, findUsersByIds, findUserById, getUserDisplayName, isUserAdmin } from "~/models/user";
import { prependOwnerToTeamName, stripOwnerFromTeamName, generateUniqueTeamNames } from "~/utils/team-names";
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
import { findDraftableSportsSeasons, findSportsSeasonsByIds } from "~/models/sports-season";
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { deleteAllDraftPicks } from "~/models/draft-pick";
import { clearAllQueuesForSeason } from "~/models/draft-queue";
import { deleteSeasonTimers } from "~/models/draft-timer";
import { logCommissionerAction } from "~/models/audit-log";
import { parseDraftSpeed } from "~/lib/draft-timer";
import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
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";
import { TimerModeSelector } from "~/components/league/TimerModeSelector";
import { DraftSpeedPicker } from "~/components/league/DraftSpeedPicker";
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings";
import { ScoringPresetPicker, OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker";
import { toast } from "sonner";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
syncPrivateBracktParticipants,
} from "~/services/brackt.server";
function isValidHHMM(s: string): boolean {
return /^\d{2}:\d{2}$/.test(s);
}
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
}
const BRACKT_SLUG = "brackt";
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
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,
});
}
await seedOrRepairBracktTemplate();
// Get current season with sports and draftable seasons in parallel
const [season, draftableSportsSeasons] = await Promise.all([
findCurrentSeasonWithSports(leagueId),
findDraftableSportsSeasons(),
]);
const teams = season ? await findTeamsBySeasonId(season.id) : [];
// Merge draftable seasons with any already-linked non-draftable seasons so that
// previously-added seasons remain visible and checked in the UI even after being disabled.
const today = new Date().toISOString().slice(0, 10);
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
const linkedButNotDraftable = (season?.seasonSports ?? [])
.map((s) => s.sportsSeason)
.filter((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return !draftableIds.has(ss.id) && (fantasySeasonId || ss.draftOff < today || ss.draftOn > today);
});
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
// Count teams with owners
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
// Check if user is admin
const isAdmin = await isUserAdmin(userId);
// Get all users (only for admins - used in team assignment dropdown)
const allUsers = isAdmin ? await findAllUsers() : [];
// Get commissioners for this league with user info
const commissioners = await findCommissionersByLeagueId(leagueId);
// Batch-fetch all users needed for commissioner and owner lookups in one query
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
const commissionerUserIds = commissioners.map((c) => c.userId);
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
const userRows = await findUsersByIds(allUserIds);
const userById = new Map(userRows.map((u) => [u.id, u]));
const commissionerUserData = commissioners.map((c) => {
const user = userById.get(c.userId);
return {
...c,
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
};
});
const validOwners = uniqueOwnerIds
.map((ownerId) => {
const user = userById.get(ownerId);
return user ? { id: user.id, name: getUserDisplayName(user) } : null;
})
.filter((o): o is NonNullable<typeof o> => o !== null);
const ownerMap = new Map(validOwners.map((o) => [o.id, o.name]));
// League members (team owners) - available to all commissioners for adding co-commissioners
const leagueMembers = validOwners.map((o) => ({
id: o.id,
name: o.name,
}));
const commishTimezone = (await findUserById(userId))?.timezone ?? null;
return {
league,
season,
teams,
teamCount: teams.length,
teamsWithOwners,
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & {
fantasySeasonId: string | null;
sport: { id: string; name: string; type: string; slug: string };
}>,
draftSlots,
isAdmin,
allUsers,
leagueMembers,
ownerMap: Object.fromEntries(ownerMap),
commissioners: commissionerUserData,
currentUserId: userId,
commishTimezone,
};
}
export async function action(args: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
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");
// For league-level settings (name, public draft board), save immediately
// before checking season, so they work even if no season exists
if (intent === "update") {
const name = formData.get("name");
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
const discordWebhookUrl = formData.get("discordWebhookUrl");
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" };
}
const webhookUrl =
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
}
try {
const changedFields: string[] = [];
if (name.trim() !== league.name) changedFields.push("name");
if (isPublicDraftBoard !== league.isPublicDraftBoard) changedFields.push("isPublicDraftBoard");
if ((webhookUrl || null) !== league.discordWebhookUrl) changedFields.push("discordWebhookUrl");
await updateLeague(leagueId, {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
});
if (changedFields.length > 0) {
const currentSeason = await findCurrentSeasonWithSports(leagueId);
if (currentSeason) {
await logCommissionerAction({
seasonId: currentSeason.id,
leagueId,
actorUserId: userId,
action: "league_settings_changed",
details: {
changedFields,
previousValues: {
name: league.name,
isPublicDraftBoard: league.isPublicDraftBoard,
discordWebhookUrl: league.discordWebhookUrl,
},
newValues: {
name: name.trim(),
isPublicDraftBoard,
discordWebhookUrl: webhookUrl || null,
},
},
});
}
}
} catch (error) {
logger.error("Error updating league:", error);
return { error: "Failed to update league. Please try again." };
}
}
if (intent === "test-discord-webhook") {
const webhookUrl = formData.get("webhookUrl");
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
return { error: "Invalid Discord webhook URL" };
}
try {
const testSeason = await findCurrentSeasonWithSports(leagueId);
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
await sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings: [
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
],
previousStandings: new Map([
["1", 125],
["2", 125],
["3", 100],
]),
previousRanks: new Map([
["1", 2],
["2", 1],
["3", 3],
]),
eventName: "Test Notification",
scoredMatches: [
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
],
});
return { testSuccess: true };
} catch (err) {
logger.error("Discord test webhook failed:", err);
return { error: "Failed to send test notification. Check your webhook URL." };
}
}
// Get current season to check status
const season = await findCurrentSeasonWithSports(leagueId);
if (!season) {
if (intent === "update") {
return redirect(`/leagues/${leagueId}?updated=true`);
}
return { error: "No active season found" };
}
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);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_order_set",
affectedTeamIds: teamIds,
details: {
order: teamIds.map((id, i) => ({
teamId: id,
teamName: teams.find((t) => t.id === id)?.name ?? id,
position: i + 1,
})),
},
});
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);
// Fetch the new order that was just written so we can log it accurately
const newSlots = await findDraftSlotsBySeasonId(season.id);
const sortedSlots = newSlots.toSorted((a, b) => a.draftOrder - b.draftOrder);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_order_randomized",
affectedTeamIds: sortedSlots.map((s) => s.teamId),
details: {
order: sortedSlots.map((s) => ({
teamId: s.teamId,
teamName: teams.find((t) => t.id === s.teamId)?.name ?? s.teamId,
position: s.draftOrder,
})),
},
});
return { success: true, message: "Draft order randomized successfully" };
}
if (intent === "remove-team-owner") {
const teamId = formData.get("teamId") as string;
if (!teamId) {
return { error: "Team ID is required" };
}
try {
const team = await findTeamById(teamId);
if (team?.ownerId) {
const owner = await findUserById(team.ownerId);
const username = owner?.username ?? owner?.displayName;
if (username) {
const strippedName = stripOwnerFromTeamName(team.name, username);
if (strippedName !== team.name) {
await renameTeam(teamId, strippedName);
}
await removeTeamOwner(teamId);
} else {
await removeTeamOwner(teamId);
}
} else {
await removeTeamOwner(teamId);
}
return { success: true, message: "Owner removed successfully" };
} catch (error) {
logger.error("Error removing team owner:", error);
return { error: "Failed to remove owner. Please try again." };
}
}
if (intent === "assign-team-owner") {
const teamId = formData.get("teamId") as string;
const assignedUserId = formData.get("userId") as string;
if (!teamId || !assignedUserId) {
return { error: "Team ID and User ID are required" };
}
// Check if user is admin (only admins can assign owners)
const isAdmin = await isUserAdmin(userId);
if (!isAdmin) {
return { error: "Only admins can assign team owners" };
}
// Look up user before assigning to fail fast and get their name
const assignedUser = await findUserById(assignedUserId);
if (!assignedUser) {
return { error: "User not found. Please try again." };
}
// Check if user is already assigned to a team in this season
const teams = await findTeamsBySeasonId(season.id);
const userAlreadyHasTeam = teams.some(team => team.ownerId === assignedUserId);
if (userAlreadyHasTeam) {
return { error: "This user is already assigned to a team in this league" };
}
// Check if the target team already has an owner
const targetTeam = teams.find(team => team.id === teamId);
if (!targetTeam) {
return { error: "Team not found" };
}
if (targetTeam.ownerId) {
return { error: "This team already has an owner. Remove the current owner first." };
}
try {
const teamName = prependOwnerToTeamName(
targetTeam.name,
assignedUser.username ?? assignedUser.displayName ?? "Member"
);
await claimTeam(teamId, assignedUserId, teamName);
return { success: true, message: "Owner assigned successfully" };
} catch (error) {
logger.error("Error assigning team owner:", error);
return { error: "Failed to assign owner. Please try again." };
}
}
if (intent === "reset-draft") {
// Check if user is admin (only admins can reset draft)
const isAdmin = await isUserAdmin(userId);
if (!isAdmin) {
return { error: "Only admins can reset the draft" };
}
try {
// Delete all draft picks
await deleteAllDraftPicks(season.id);
// Clear all draft queues
await clearAllQueuesForSeason(season.id);
// Delete all draft timers
await deleteSeasonTimers(season.id);
const previousPickNumber = season.currentPickNumber;
// Set season status back to pre_draft and reset draft state (keeps draft order intact)
await updateSeason(season.id, {
status: "pre_draft",
currentPickNumber: null,
draftStartedAt: null,
draftPaused: false,
});
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_reset",
details: { previousPickNumber },
});
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
} catch (error) {
logger.error("Error resetting draft:", error);
return { error: "Failed to reset draft. Please try again." };
}
}
if (intent === "delete") {
// Delete the league
await deleteLeague(leagueId);
// Redirect to home with success message
return redirect("/?deleted=true");
}
if (intent === "update") {
const teamCount = formData.get("teamCount");
const draftDateTime = formData.get("draftDateTime");
const draftRounds = formData.get("draftRounds");
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = formData.get("draftTimerMode") as "chess_clock" | "standard" | null;
// League-level fields (name, isPublicDraftBoard) are already saved above.
// Now handle season-specific updates.
try {
// Update season settings
const seasonUpdates: Partial<NewSeason> = {};
// Handle draft rounds (only if value actually changed)
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" };
}
if (draftRoundsNum !== season.draftRounds) {
seasonUpdates.draftRounds = draftRoundsNum;
}
}
}
// Handle draft date/time (only if value actually changed)
if (typeof draftDateTime === "string") {
const newDateTime = draftDateTime ? new Date(draftDateTime) : null;
const currentDateTime = season.draftDateTime ? new Date(season.draftDateTime) : null;
const changed = newDateTime?.getTime() !== currentDateTime?.getTime();
if (changed) {
seasonUpdates.draftDateTime = newDateTime;
}
}
// Set draft times from speed preset (only if draftSpeed was submitted and values changed)
if (draftSpeed !== null) {
const { draftInitialTime, draftIncrementTime } = parseDraftSpeed(
draftSpeed as string | null,
draftTimerMode ?? "chess_clock"
);
if (draftInitialTime !== season.draftInitialTime) {
seasonUpdates.draftInitialTime = draftInitialTime;
}
if (draftIncrementTime !== season.draftIncrementTime) {
seasonUpdates.draftIncrementTime = draftIncrementTime;
}
}
// Timer mode (only if submitted and changed)
if (draftTimerMode !== null && draftTimerMode !== season.draftTimerMode) {
seasonUpdates.draftTimerMode = draftTimerMode;
}
// Overnight pause settings
const overnightPauseMode = formData.get("overnightPauseMode") as "none" | "league" | "per_user" | null;
if (overnightPauseMode && ["none", "league", "per_user"].includes(overnightPauseMode)) {
if (overnightPauseMode !== season.overnightPauseMode) {
seasonUpdates.overnightPauseMode = overnightPauseMode;
}
if (overnightPauseMode !== "none") {
const start = formData.get("overnightPauseStart") as string | null;
const end = formData.get("overnightPauseEnd") as string | null;
const tz = formData.get("overnightPauseTimezone") as string | null;
if (start && isValidHHMM(start) && start !== season.overnightPauseStart) {
seasonUpdates.overnightPauseStart = start;
}
if (end && isValidHHMM(end) && end !== season.overnightPauseEnd) {
seasonUpdates.overnightPauseEnd = end;
}
if (tz && tz.length > 0 && tz !== season.overnightPauseTimezone) {
seasonUpdates.overnightPauseTimezone = tz;
}
}
}
// Handle scoring rules (only if in pre_draft status and values changed)
if (season.status === "pre_draft") {
const scoringFields = [
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
];
for (const field of scoringFields) {
const value = formData.get(field);
if (typeof value === "string") {
const points = parseInt(value, 10);
if (!isNaN(points)) {
if (points < 0 || points > 1000) {
return { error: `${field} must be between 0 and 1000 points` };
}
if (points !== (season as unknown as Record<string, unknown>)[field]) {
(seasonUpdates as Record<string, unknown>)[field] = points;
}
}
}
}
}
// Update season if there are changes
if (Object.keys(seasonUpdates).length > 0) {
await updateSeason(season.id, seasonUpdates);
const scoringFields = ["pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"];
const draftFields = Object.keys(seasonUpdates).filter((k) => !scoringFields.includes(k));
const scoringChangedFields = Object.keys(seasonUpdates).filter((k) => scoringFields.includes(k));
const seasonAsMap = season as unknown as Record<string, unknown>;
const updatesAsMap = seasonUpdates as Record<string, unknown>;
if (draftFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "draft_settings_changed",
details: {
changedFields: draftFields,
previousValues: Object.fromEntries(draftFields.map((k) => [k, seasonAsMap[k]])),
newValues: Object.fromEntries(draftFields.map((k) => [k, updatesAsMap[k]])),
},
});
}
if (scoringChangedFields.length > 0) {
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "scoring_rules_changed",
details: {
changedFields: scoringChangedFields,
previousValues: Object.fromEntries(scoringChangedFields.map((k) => [k, seasonAsMap[k]])),
newValues: Object.fromEntries(scoringChangedFields.map((k) => [k, updatesAsMap[k]])),
},
});
}
}
// Handle sports changes (only valid in pre_draft)
if (season.status === "pre_draft") {
const selectedSports = formData.getAll("sportsSeasons").map(String);
const currentSportIds = new Set(
season.seasonSports?.map((s) => s.sportsSeason.id) || []
);
const sportsToApply = await applyBracktSportsForSeason(season.id, selectedSports);
const newSportIds = new Set(sportsToApply);
for (const sportId of currentSportIds) {
if (!newSportIds.has(sportId)) {
await unlinkSportFromSeason(season.id, sportId);
}
}
const sportsToAdd = [];
for (const sportId of newSportIds) {
if (!currentSportIds.has(sportId)) {
sportsToAdd.push({
seasonId: season.id,
sportsSeasonId: sportId,
});
}
}
if (sportsToAdd.length > 0) {
await linkMultipleSportsToSeason(sportsToAdd);
}
const removedIds = [...currentSportIds].filter((id) => !newSportIds.has(id));
const addedIds = [...newSportIds].filter((id) => !currentSportIds.has(id));
if (removedIds.length > 0 || addedIds.length > 0) {
const nameMap = new Map(
season.seasonSports?.map((s) => [s.sportsSeason.id, `${s.sportsSeason.sport.name} ${s.sportsSeason.year}`]) ?? []
);
const addedSeasons = await findSportsSeasonsByIds(addedIds);
const addedNameMap = new Map(addedSeasons.map((ss) => [ss.id, `${ss.sport.name} ${ss.year}`]));
const addedNames = addedIds.map((id) => addedNameMap.get(id) ?? id);
await logCommissionerAction({
seasonId: season.id,
leagueId,
actorUserId: userId,
action: "sports_changed",
details: {
added: addedNames,
removed: removedIds.map((id) => nameMap.get(id) ?? id),
},
});
}
}
// 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) {
const newNames = generateUniqueTeamNames(newTeamCount - currentTeamCount);
const teamsToAdd = Array.from(
{ length: newTeamCount - currentTeamCount },
(_, i) => ({
seasonId: season.id,
name: newNames[i],
ownerId: null as null,
})
);
// Fetch existing slots before the transaction to determine append position
const existingSlots = await findDraftSlotsBySeasonId(season.id);
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
// Create teams and their draft slots atomically
const db = database();
await db.transaction(async (tx) => {
const newTeams = await tx
.insert(schema.teams)
.values(teamsToAdd)
.returning();
await tx.insert(schema.draftSlots).values(
newTeams.map((team, i) => ({
seasonId: season.id,
teamId: team.id,
draftOrder: maxOrder + i + 1,
}))
);
});
await syncPrivateBracktParticipants(season.id);
} else if (newTeamCount < currentTeamCount) {
if (season.status !== "pre_draft") {
return { error: "Teams cannot be removed after the draft has started" };
}
// 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);
}
await syncPrivateBracktParticipants(season.id);
}
}
}
return redirect(`/leagues/${leagueId}?updated=true`);
} catch (error) {
logger.error("Error updating season settings:", error);
return { error: "Failed to update season settings. Please try again." };
}
}
if (intent === "add-commissioner") {
const newCommissionerUserId = formData.get("userId") as string;
if (!newCommissionerUserId) {
return { error: "User is required" };
}
// Check if user already has a commissioner record (don't use isCommissioner — it
// returns true for site admins, causing a false "already a commissioner" error)
const alreadyCommissioner = await hasCommissionerRecord(leagueId, newCommissionerUserId);
if (alreadyCommissioner) {
return { error: "This user is already a commissioner" };
}
try {
await createCommissioner({ leagueId, userId: newCommissionerUserId });
return { success: true, message: "Commissioner added successfully" };
} catch (error) {
logger.error("Error adding commissioner:", error);
return { error: "Failed to add commissioner. Please try again." };
}
}
if (intent === "remove-commissioner") {
const commissionerUserId = formData.get("commissionerUserId") as string;
if (!commissionerUserId) {
return { error: "User is required" };
}
// Prevent self-removal
if (commissionerUserId === userId) {
return { error: "You cannot remove yourself as a commissioner" };
}
// Prevent removing the last commissioner
const count = await countCommissionersByLeagueId(leagueId);
if (count <= 1) {
return { error: "Cannot remove the last commissioner" };
}
try {
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
return { success: true, message: "Commissioner removed successfully" };
} catch (error) {
logger.error("Error removing commissioner:", error);
return { error: "Failed to remove commissioner. Please try again." };
}
}
return { error: "Invalid action" };
}
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone } = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock");
const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none");
const [overnightStart, setOvernightStart] = useState(season?.overnightPauseStart ?? "23:00");
const [overnightEnd, setOvernightEnd] = useState(season?.overnightPauseEnd ?? "07:00");
const [overnightTimezone, setOvernightTimezone] = useState(season?.overnightPauseTimezone ?? commishTimezone ?? "");
const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => {
const r: ScoringRules = {
pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st,
pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd,
pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd,
pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th,
pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th,
pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th,
pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th,
pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th,
};
if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "brackt";
if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => r[k as keyof ScoringRules] === v)) return "omnifantasy";
return "custom";
});
const [scoringRules, setScoringRules] = useState<ScoringRules>({
pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st,
pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd,
pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd,
pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th,
pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th,
pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th,
pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th,
pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th,
});
const initTimerMode = season?.draftTimerMode ?? "chess_clock";
const [draftSpeed, setDraftSpeed] = useState(() => {
if (initTimerMode === "standard") return season?.draftIncrementTime?.toString() ?? "90";
if (season?.draftInitialTime === 60 && season?.draftIncrementTime === 10) return "fast";
if (season?.draftInitialTime === 120 && season?.draftIncrementTime === 15) return "standard";
if (season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600) return "slow";
if (season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600) return "very-slow";
return "standard";
});
const [selectedSports, setSelectedSports] = useState<Set<string>>(
() => {
const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? [];
const linkedIds = linkedSeasons.map((ss) => ss.id);
const templateBrackt = allSportsSeasons.find((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId;
});
const privateBrackt = linkedSeasons.find((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId;
});
if (templateBrackt && privateBrackt) {
return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id)));
}
return new Set(linkedIds);
}
);
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => 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) => slot.teamId)
: teams.map((team) => 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 displaySportsSeasons = (() => {
const templateBrackt = allSportsSeasons.find((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return ss.sport.slug === BRACKT_SLUG && !fantasySeasonId;
});
if (!templateBrackt) return allSportsSeasons;
return allSportsSeasons.filter((ss) => {
const fantasySeasonId = (ss as typeof ss & { fantasySeasonId?: string | null }).fantasySeasonId;
return !(ss.sport.slug === BRACKT_SLUG && !!fantasySeasonId);
});
})();
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) => t.id === teamId);
};
const [copied, setCopied] = useState(false);
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`;
try {
await navigator.clipboard.writeText(inviteUrl);
setCopied(true);
toast.success("Invite link copied to clipboard!");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy invite link");
}
};
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>
{season?.inviteCode && (
<Card className="mb-6">
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league
{teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={`${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()}
/>
<Button
onClick={handleCopyInviteLink}
variant={copied ? "default" : "outline"}
size="sm"
>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
)}
<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="flex items-center space-x-2">
<input
type="checkbox"
id="isPublicDraftBoard"
name="isPublicDraftBoard"
defaultChecked={league.isPublicDraftBoard}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
Make draft board publicly accessible (no login required)
</Label>
</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>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Get Discord alerts when standings change after scoring events
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
<Input
id="discordWebhookUrl"
name="discordWebhookUrl"
type="url"
defaultValue={league.discordWebhookUrl ?? ""}
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-sm text-muted-foreground">
Create a webhook in your Discord server under Server Settings → Integrations → Webhooks.
</p>
</div>
{league.discordWebhookUrl && (
<Form method="post" className="pt-2">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<Button type="submit" variant="outline" size="sm">
Send Test Notification
</Button>
</Form>
)}
{actionData && "testSuccess" in actionData && actionData.testSuccess && (
<p className="text-sm text-emerald-600">Test notification sent to Discord!</p>
)}
</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}
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>
<div className="space-y-2">
<Label>Timer Mode</Label>
<TimerModeSelector value={timerMode} onChange={setTimerMode} disabled={!canEditDraftRounds} />
<input type="hidden" name="draftTimerMode" value={timerMode} />
</div>
<div className="space-y-2">
<Label>Draft Speed</Label>
<DraftSpeedPicker timerMode={timerMode} value={draftSpeed} onChange={setDraftSpeed} disabled={!canEditDraftRounds} />
<input type="hidden" name="draftSpeed" value={draftSpeed} />
</div>
</CardContent>
</Card>
{/* Overnight Pause */}
<Card>
<CardHeader>
<CardTitle>Overnight Pause</CardTitle>
<CardDescription>
Protect players from having their pick timer expire while they&apos;re asleep.
Autodraft will still fire for teams that have it enabled.
</CardDescription>
</CardHeader>
<CardContent>
<OvernightPauseSettings
show={true}
mode={overnightMode}
onModeChange={setOvernightMode}
start={overnightStart}
onStartChange={setOvernightStart}
end={overnightEnd}
onEndChange={setOvernightEnd}
timezone={overnightTimezone}
onTimezoneChange={setOvernightTimezone}
commishTimezone={commishTimezone}
disabled={!canEditDraftRounds}
/>
<input type="hidden" name="overnightPauseMode" value={overnightMode} />
<input type="hidden" name="overnightPauseStart" value={overnightStart} />
<input type="hidden" name="overnightPauseEnd" value={overnightEnd} />
<input type="hidden" name="overnightPauseTimezone" value={overnightTimezone} />
</CardContent>
</Card>
{/* Scoring Rules */}
<Card>
<CardHeader>
<CardTitle>Scoring Rules</CardTitle>
<CardDescription>
Points awarded for each finishing position in each sports season.
</CardDescription>
</CardHeader>
<CardContent>
<ScoringPresetPicker
preset={scoringPreset}
onPresetChange={setScoringPreset}
rules={scoringRules}
onRulesChange={setScoringRules}
disabled={!season || season.status !== "pre_draft"}
/>
{(Object.entries(scoringRules) as [string, number][]).map(([k, v]) => (
<input key={k} type="hidden" name={k} value={v} />
))}
</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">
{displaySportsSeasons.length > 0 ? (
displaySportsSeasons.map((ss) => (
<div key={ss.id} className="flex items-center space-x-2">
<Checkbox
id={ss.id}
name="sportsSeasons"
value={ss.id}
checked={selectedSports.has(ss.id)}
onCheckedChange={() => handleSportToggle(ss.id)}
disabled={!canEditSports}
/>
<Label
htmlFor={ss.id}
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
>
{ss.sport.slug === BRACKT_SLUG ? (
<span className="font-medium">Brackt</span>
) : (
<>
<span className="font-medium">{ss.sport.name}</span> - {ss.name} ({ss.year})
</>
)}
<Badge variant="outline" className="ml-2 text-xs">
{ss.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-emerald-500/15 text-emerald-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 id="draft-order">
<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>
{team.ownerId && (
<p className="text-xs text-muted-foreground">{ownerMap[team.ownerId] ?? "Unknown"}</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-emerald-500/15 text-emerald-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>
{/* Team Management */}
<Card>
<CardHeader>
<CardTitle>Team Management</CardTitle>
<CardDescription>
Manage team ownership for this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div
key={team.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">{team.name}</p>
<p className="text-sm text-muted-foreground">
{team.ownerId ? (
<span>Owner: {ownerName || "Unknown"}</span>
) : (
<span>No owner</span>
)}
</p>
</div>
<div className="flex gap-2">
{team.ownerId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove Owner
</Button>
</Form>
)}
{isAdmin && (
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="assign-team-owner" />
<input type="hidden" name="teamId" value={team.id} />
<Select name="userId" required>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
{allUsers.map((user) => {
const userOwnsTeam = teams.some((t) => t.ownerId === user.id);
return (
<SelectItem
key={user.id}
value={user.id}
disabled={userOwnsTeam}
>
{user.username || user.email}
{userOwnsTeam && " (already in league)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Assign
</Button>
</Form>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Commissioner Management */}
<Card>
<CardHeader>
<CardTitle>Commissioner Management</CardTitle>
<CardDescription>
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{actionData?.error && !actionData?.success && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && actionData?.message && (
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
{actionData.message}
</div>
)}
<div className="space-y-3">
{commissioners.map((commissioner) => (
<div
key={commissioner.id}
className="flex items-center justify-between p-3 border rounded-md"
>
<div className="flex-1">
<p className="font-medium">
{commissioner.userName}
{commissioner.userId === currentUserId && (
<span className="text-xs text-muted-foreground ml-2">(you)</span>
)}
</p>
{!teams.some((t) => t.ownerId === commissioner.userId) && (
<p className="text-xs text-muted-foreground">No team in current season</p>
)}
</div>
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
<Form method="post">
<input type="hidden" name="intent" value="remove-commissioner" />
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Remove
</Button>
</Form>
)}
</div>
))}
</div>
<div className="border-t pt-4">
<p className="text-sm font-medium mb-3">Add Commissioner</p>
<Form method="post" className="flex gap-2">
<input type="hidden" name="intent" value="add-commissioner" />
<Select name="userId" required>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select a user" />
</SelectTrigger>
<SelectContent>
{leagueMembers.map((member) => {
const alreadyCommissioner = commissioners.some(
(c) => c.userId === member.id
);
return (
<SelectItem
key={member.id}
value={member.id}
disabled={alreadyCommissioner}
>
{member.name || "Unknown"}
{alreadyCommissioner && " (already commissioner)"}
</SelectItem>
);
})}
</SelectContent>
</Select>
<Button
type="submit"
variant="outline"
size="sm"
disabled={navigation.state === "submitting"}
>
Add
</Button>
</Form>
</div>
</CardContent>
</Card>
{/* Audit Log */}
<Card>
<CardHeader>
<CardTitle>Audit Log</CardTitle>
<CardDescription>
View the full history of commissioner actions for this season. All league members can see this log.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>
View Full Audit Log
</Link>
</Button>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible and destructive actions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Reset Draft - Admin Only */}
{isAdmin && season && (
<div className="space-y-2">
<h3 className="font-medium">Reset Draft</h3>
<p className="text-sm text-muted-foreground">
Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.
</p>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-full">
Reset Draft
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
<AlertDialogDescription>
This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status.
The draft order will remain intact. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Form method="post">
<input type="hidden" name="intent" value="reset-draft" />
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
Reset Draft
</AlertDialogAction>
</Form>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
{/* Delete League */}
<div className="space-y-2">
<h3 className="font-medium">Delete League</h3>
<p className="text-sm text-muted-foreground">
Permanently delete this league and all associated data.
</p>
<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>
</div>
</CardContent>
</Card>
</div>
</div>
);
}