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

505 lines
23 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
import { Form, useBeforeUnload, useBlocker, useNavigation } from "react-router";
import { format } from "date-fns";
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
import {
AlertTriangle,
Bell,
ListOrdered,
Shield,
Swords,
TrendingUp,
Trophy,
Users,
} from "lucide-react";
import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types";
import type { Route } from "./+types/$leagueId.settings";
export { action, loader } from "./$leagueId.settings.server";
import { OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { SettingsMessage } from "~/components/league/settings/SettingsMessages";
import { LeagueSettingsHeader } from "~/components/league/settings/LeagueSettingsHeader";
import {
SettingsDesktopNav,
SettingsMobileGridNav,
SettingsMobileSectionPill,
type SettingsGridSection,
} from "~/components/league/settings/SettingsNavigation";
import { SettingsSaveBar } from "~/components/league/settings/SettingsSaveBar";
import { LeaveSettingsDialog, SwitchSettingsSectionDialog } from "~/components/league/settings/UnsavedChangesDialogs";
import { DraftOrderSection } from "~/components/league/settings/DraftOrderSection";
import { LeagueBasicsSection } from "~/components/league/settings/LeagueBasicsSection";
import { DraftSetupSection } from "~/components/league/settings/DraftSetupSection";
import { SportsSection } from "~/components/league/settings/SportsSection";
import { ScoringSection } from "~/components/league/settings/ScoringSection";
import { NotificationsSection } from "~/components/league/settings/NotificationsSection";
import { PeopleSection } from "~/components/league/settings/PeopleSection";
import { HistoryDangerSection } from "~/components/league/settings/HistoryDangerSection";
2026-03-05 10:07:15 -08:00
import { toast } from "sonner";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
}
const BRACKT_SLUG = "brackt";
type LoaderSeason = Route.ComponentProps["loaderData"]["season"];
type LoaderSportsSeason = Route.ComponentProps["loaderData"]["allSportsSeasons"][number] & {
fantasySeasonId?: string | null;
};
function getInitialScoringPreset(season: LoaderSeason) {
const rules: 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]) => rules[k as keyof ScoringRules] === v)) return "brackt" as const;
if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => rules[k as keyof ScoringRules] === v)) return "omnifantasy" as const;
return "custom" as const;
}
function hasFantasySeasonId(season: object): boolean {
return "fantasySeasonId" in season && typeof season.fantasySeasonId === "string" && season.fantasySeasonId.length > 0;
}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
function findTemplateBrackt(seasons: LoaderSportsSeason[]): LoaderSportsSeason | undefined {
return seasons.find((ss) => ss.sport.slug === BRACKT_SLUG && !hasFantasySeasonId(ss));
}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
function getInitialSelectedSports({
season,
allSportsSeasons,
}: {
season: LoaderSeason;
allSportsSeasons: LoaderSportsSeason[];
}) {
const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? [];
const linkedIds = linkedSeasons.map((ss) => ss.id);
const templateBrackt = findTemplateBrackt(allSportsSeasons);
const privateBrackt = linkedSeasons.find((ss) => ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss));
if (templateBrackt && privateBrackt) {
return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id)));
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
}
return new Set(linkedIds);
}
const SETTINGS_SECTIONS = [
{ id: "league-basics", label: "League Basics" },
{ id: "draft-setup", label: "Draft Settings" },
{ id: "draft-order", label: "Draft Order" },
{ id: "sports", label: "Sports" },
{ id: "scoring", label: "Scoring" },
{ id: "notifications", label: "Notifications" },
{ id: "people", label: "People" },
{ id: "history-danger", label: "History & Danger" },
] as const;
type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]["id"];
const UPDATE_SECTIONS: SettingsSectionId[] = [
"league-basics",
"draft-setup",
"sports",
"scoring",
"notifications",
];
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
const {
league, season, teams, teamCount, teamsWithOwners, allSportsSeasons,
draftSlots, isAdmin, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone,
} = loaderData;
const navigation = useNavigation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
const [activeSection, setActiveSection] = useState<SettingsSectionId>("league-basics");
const [pendingSection, setPendingSection] = useState<SettingsSectionId | null>(null);
// Two separate dirty flags: one for the multi-section settings form, one for draft order
const [hasUnsavedSettingsChanges, setHasUnsavedSettingsChanges] = useState(false);
const [hasDraftOrderChanges, setHasDraftOrderChanges] = useState(false);
// Settings form state
const [leagueName, setLeagueName] = useState(league.name);
const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard);
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? "");
const [teamCountValue, setTeamCountValue] = useState<number>(teamCount);
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">(() => getInitialScoringPreset(season));
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>>(
() => getInitialSelectedSports({ season, allSportsSeasons })
);
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") : ""
);
// Draft order state (separate form, separate dirty flag)
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id)
);
const [copied, setCopied] = useState(false);
// Sync draft order from loader after randomization or other server updates
useEffect(() => {
const savedOrder = draftSlots.length > 0
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
? draftSlots.map((slot) => slot.teamId)
: teams.map((team) => team.id);
setDraftOrderTeams(savedOrder);
setHasDraftOrderChanges(false);
}, [draftSlots, teams]);
// Clear settings dirty flag after a successful (non-error) submission
useEffect(() => {
if (!hasUnsavedSettingsChanges || navigation.state !== "idle") return;
if (actionData && !("error" in actionData)) {
setHasUnsavedSettingsChanges(false);
}
}, [actionData, hasUnsavedSettingsChanges, navigation.state]);
const canEditSports = season?.status === "pre_draft";
const canEditTeamCount = season?.status === "pre_draft";
const canEditDraftOrder = season?.status === "pre_draft";
const canEditDraftRounds = season?.status === "pre_draft";
const minTeamCount = Math.max(6, teamsWithOwners);
const sportsCount = selectedSports.size;
const minRounds = sportsCount;
const recommendedRounds = Math.ceil(sportsCount * 1.25);
const flexSpots = Math.max(0, draftRounds - sportsCount);
const updateIntent = navigation.formData?.get("intent");
const draftOrderSet = draftSlots.length === teams.length && teams.length > 0;
const showUpdateForm = UPDATE_SECTIONS.includes(activeSection);
const isSubmittingSettings = navigation.state === "submitting" && updateIntent === "update";
const isSubmittingDraftOrder = navigation.state === "submitting" && updateIntent === "set-draft-order";
const shouldWarnUnsaved = (hasUnsavedSettingsChanges || hasDraftOrderChanges) && !isSubmittingSettings && !isSubmittingDraftOrder;
const blocker = useBlocker(shouldWarnUnsaved);
const inviteUrl = season?.inviteCode
? `${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`
: "";
const displaySportsSeasons = (() => {
const templateBrackt = findTemplateBrackt(allSportsSeasons);
if (!templateBrackt) return allSportsSeasons;
return allSportsSeasons.filter((ss) => !(ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss)));
})();
useBeforeUnload(
(event) => {
if (!shouldWarnUnsaved) return;
event.preventDefault();
event.returnValue = "";
},
{ capture: true }
);
const markDirty = () => setHasUnsavedSettingsChanges(true);
const resetSettingsFormState = () => {
setLeagueName(league.name);
setIsPublicDraftBoard(league.isPublicDraftBoard);
setDiscordWebhookUrl(league.discordWebhookUrl ?? "");
setTeamCountValue(teamCount);
setTimerMode(season?.draftTimerMode ?? "chess_clock");
setOvernightMode(season?.overnightPauseMode ?? "none");
setOvernightStart(season?.overnightPauseStart ?? "23:00");
setOvernightEnd(season?.overnightPauseEnd ?? "07:00");
setOvernightTimezone(season?.overnightPauseTimezone ?? commishTimezone ?? "");
setScoringPreset(getInitialScoringPreset(season));
setScoringRules({
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,
});
setSelectedSports(getInitialSelectedSports({ season, allSportsSeasons }));
setDraftRounds(season?.draftRounds || 20);
setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined);
setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "");
setHasUnsavedSettingsChanges(false);
};
const resetDraftOrder = () => {
setDraftOrderTeams(
draftSlots.length > 0 ? draftSlots.map((s) => s.teamId) : teams.map((t) => t.id)
);
setHasDraftOrderChanges(false);
};
const handleSectionChange = (sectionId: SettingsSectionId, { mobile = false } = {}) => {
if (sectionId === activeSection && !mobile) return;
if (hasUnsavedSettingsChanges) {
setPendingSection(sectionId);
return;
}
setActiveSection(sectionId);
if (mobile) setMobileView("section");
};
const discardChangesAndSwitchSection = () => {
if (!pendingSection) return;
resetSettingsFormState();
setActiveSection(pendingSection);
setPendingSection(null);
setMobileView("section");
};
2026-03-05 10:07:15 -08:00
const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return;
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");
}
};
const handleSportToggle = (sportId: string) => {
setSelectedSports((prev) => {
const next = new Set(prev);
if (next.has(sportId)) {
next.delete(sportId);
} else {
next.add(sportId);
}
return next;
});
};
const sectionGridData: SettingsGridSection[] = [
{ id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join", isComplete: true },
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds", isComplete: !!(draftDate && draftTime) },
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize", isComplete: draftOrderSet },
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected`, isComplete: selectedSports.size > 0 },
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties", isComplete: true },
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts", isComplete: !!league.discordWebhookUrl },
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams`, isComplete: false },
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isComplete: false, isDanger: true },
];
const completedSectionCount = sectionGridData.filter((s) => s.isComplete).length;
return (
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
<div className={cn(mobileView === "section" && "hidden lg:block")}>
<LeagueSettingsHeader leagueId={league.id} leagueName={league.name} />
</div>
{mobileView === "grid" && (
<div className="lg:hidden">
<SettingsMobileGridNav
sections={sectionGridData}
completedCount={completedSectionCount}
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
/>
</div>
2026-03-05 10:07:15 -08:00
)}
{mobileView === "section" && (
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
)}
2026-03-05 10:07:15 -08:00
<div className={cn("grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]", mobileView === "grid" && "hidden lg:grid")}>
<SettingsDesktopNav
sections={sectionGridData}
activeSection={activeSection}
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId)}
/>
<main className="space-y-6">
{showUpdateForm && (
<Form method="post" noValidate className="space-y-6">
<input type="hidden" name="intent" value="update" />
<LeagueBasicsSection
active={activeSection === "league-basics"}
season={season ?? null}
leagueName={leagueName}
onLeagueNameChange={(v) => { setLeagueName(v); markDirty(); }}
isPublicDraftBoard={isPublicDraftBoard}
onIsPublicDraftBoardChange={(v) => { setIsPublicDraftBoard(v); markDirty(); }}
teamCountValue={teamCountValue}
onTeamCountValueChange={(v) => { setTeamCountValue(v); markDirty(); }}
teamsWithOwners={teamsWithOwners}
canEditTeamCount={canEditTeamCount}
minTeamCount={minTeamCount}
inviteUrl={inviteUrl}
copied={copied}
onCopyInviteLink={handleCopyInviteLink}
/>
<DraftSetupSection
active={activeSection === "draft-setup"}
canEditDraftRounds={canEditDraftRounds}
minRounds={minRounds}
recommendedRounds={recommendedRounds}
flexSpots={flexSpots}
draftRounds={draftRounds}
onDraftRoundsChange={(v) => { setDraftRounds(v); markDirty(); }}
draftDate={draftDate}
onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }}
draftTime={draftTime}
onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }}
timerMode={timerMode}
onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }}
draftSpeed={draftSpeed}
onDraftSpeedChange={(v) => { setDraftSpeed(v); markDirty(); }}
overnightMode={overnightMode}
onOvernightModeChange={(v) => { setOvernightMode(v); markDirty(); }}
overnightStart={overnightStart}
onOvernightStartChange={(v) => { setOvernightStart(v); markDirty(); }}
overnightEnd={overnightEnd}
onOvernightEndChange={(v) => { setOvernightEnd(v); markDirty(); }}
overnightTimezone={overnightTimezone}
onOvernightTimezoneChange={(v) => { setOvernightTimezone(v); markDirty(); }}
commishTimezone={commishTimezone}
/>
<SportsSection
active={activeSection === "sports"}
canEditSports={canEditSports}
selectedSports={selectedSports}
displaySportsSeasons={displaySportsSeasons}
onSportToggle={(id) => { handleSportToggle(id); markDirty(); }}
onClearAll={() => { setSelectedSports(new Set()); markDirty(); }}
/>
<ScoringSection
active={activeSection === "scoring"}
canEditSports={canEditSports}
season={season ?? null}
scoringPreset={scoringPreset}
onScoringPresetChange={(v) => { setScoringPreset(v); markDirty(); }}
scoringRules={scoringRules}
onScoringRulesChange={(v) => { setScoringRules(v); markDirty(); }}
/>
<NotificationsSection
active={activeSection === "notifications"}
savedDiscordWebhookUrl={league.discordWebhookUrl}
discordWebhookUrl={discordWebhookUrl}
onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }}
/>
<SettingsMessage actionData={actionData} />
<SettingsSaveBar
hasUnsavedChanges={hasUnsavedSettingsChanges}
isSubmitting={isSubmittingSettings}
onReset={resetSettingsFormState}
/>
</Form>
)}
<LeaveSettingsDialog blocker={blocker} />
<SwitchSettingsSectionDialog
open={pendingSection !== null}
onOpenChange={(open) => { if (!open) setPendingSection(null); }}
onStay={() => setPendingSection(null)}
onDiscard={discardChangesAndSwitchSection}
/>
<DraftOrderSection
active={activeSection === "draft-order"}
draftOrderSet={draftOrderSet}
canEditDraftOrder={canEditDraftOrder}
hasUnsavedChanges={hasDraftOrderChanges}
draftOrderTeams={draftOrderTeams}
setDraftOrderTeams={setDraftOrderTeams}
onDirtyChange={setHasDraftOrderChanges}
onResetChanges={resetDraftOrder}
teams={teams}
ownerMap={ownerMap}
navigationState={navigation.state}
updateIntent={updateIntent}
actionData={actionData}
/>
<PeopleSection
active={activeSection === "people"}
teams={teams}
ownerMap={ownerMap}
commissioners={commissioners}
currentUserId={currentUserId}
teamCountValue={teamCountValue}
teamsWithOwners={teamsWithOwners}
leagueMembers={leagueMembers}
isAdmin={isAdmin}
navigationState={navigation.state}
/>
{league.discordWebhookUrl && activeSection === "notifications" && (
<Form method="post" className="rounded-lg border bg-card p-4">
<input type="hidden" name="intent" value="test-discord-webhook" />
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">Test Discord notification</p>
<p className="text-sm text-muted-foreground">Sends a sample standings update to the saved webhook.</p>
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
</div>
<Button
type="submit"
variant="outline"
disabled={navigation.state === "submitting" && updateIntent === "test-discord-webhook"}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
>
Send Test
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
</Button>
</div>
</Form>
)}
<HistoryDangerSection
active={activeSection === "history-danger"}
league={{ id: league.id, name: league.name }}
season={season ?? null}
isAdmin={isAdmin}
isDeleteDialogOpen={isDeleteDialogOpen}
onDeleteDialogOpenChange={setIsDeleteDialogOpen}
navigationState={navigation.state}
/>
</main>
</div>
</div>
);
}