* Refactor league settings into per-section components (#347) Extract all settings sections from the monolithic 1009-line route file into individual components under app/components/league/settings/. Route file drops to ~300 lines. Separates draft-order dirty state from general settings dirty state, deduplicates section-change handling, and fixes several bugs found during review (typo in pointsFor5th, wrong mock in tests, lint violations). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix Stop hook loop: suppress output on typecheck success The Stop hook was producing stdout on every run, causing Claude Code to feed it back as context and rewake the model each turn. Now emits a systemMessage JSON only on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Typescript fix * Remove type asserting --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
504 lines
23 KiB
TypeScript
504 lines
23 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Form, useBeforeUnload, useBlocker, useNavigation } from "react-router";
|
|
import { format } from "date-fns";
|
|
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";
|
|
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;
|
|
}
|
|
|
|
function findTemplateBrackt(seasons: LoaderSportsSeason[]): LoaderSportsSeason | undefined {
|
|
return seasons.find((ss) => ss.sport.slug === BRACKT_SLUG && !hasFantasySeasonId(ss));
|
|
}
|
|
|
|
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)));
|
|
}
|
|
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
|
|
? 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");
|
|
};
|
|
|
|
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>
|
|
)}
|
|
|
|
{mobileView === "section" && (
|
|
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
|
)}
|
|
|
|
<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>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
disabled={navigation.state === "submitting" && updateIntent === "test-discord-webhook"}
|
|
>
|
|
Send Test
|
|
</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>
|
|
);
|
|
}
|