brackt/app/routes/leagues/$leagueId.tsx
Chris Parsons eb7aa42cef
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

407 lines
15 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { format } from "date-fns";
import type { Route } from "./+types/$leagueId";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
export { loader } from "./$leagueId.server";
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const {
league,
season,
teams,
commissioners,
currentUserId,
isUserCommissioner,
ownerMap,
commissionerMap,
availableTeamCount,
draftSlots,
sportsCount,
teamsWithOwners,
isDraftOrderSet,
sportsSeasons,
} = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (searchParams.get("updated") === "true") {
toast.success("Team updated successfully");
setSearchParams({});
}
if (searchParams.get("joined") === "true") {
toast.success("Welcome to the league! You've been assigned a team.");
setSearchParams({});
}
if (searchParams.get("left") === "true") {
toast.success("You have left the league. Your team is now available.");
setSearchParams({});
}
}, [searchParams, setSearchParams]);
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 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.name}</h1>
<div className="flex gap-2">
{teams.find((t) => t.ownerId === currentUserId) && (
<Button variant="outline" asChild>
<Link
to={`/teams/${teams.find((t) => t.ownerId === currentUserId)?.id}/settings`}
>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
</Button>
)}
</div>
</div>
{season && (
<p className="text-muted-foreground">
{season.year} Season Status:{" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</p>
)}
</div>
<div className="grid gap-6 md:grid-cols-3">
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
{/* Sports Seasons Section */}
{season && sportsSeasons.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{sportsSeasons.length} sport{sportsSeasons.length !== 1 ? "s" : ""} in this season
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{sportsSeasons.map((sportSeason) => (
<SportSeasonCard
key={sportSeason.id}
leagueId={league.id}
sportSeasonId={sportSeason.id}
sportName={sportSeason.sport.name}
seasonName={sportSeason.name}
status={sportSeason.status as "upcoming" | "active" | "completed"}
scoringPattern={sportSeason.scoringPattern}
/>
))}
</div>
</CardContent>
</Card>
)}
{isUserCommissioner && season && availableTeamCount > 0 && (
<Card>
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league (
{availableTeamCount} spot{availableTeamCount !== 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>
)}
<Card>
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this
league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId
? ownerMap[team.ownerId]
: null;
return (
<Card
key={team.id}
className={isOwned ? "border-primary" : ""}
>
<CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription>
{team.ownerId ? (
isOwned ? (
<span className="text-primary font-medium">
Your Team
</span>
) : (
<span>{ownerName || "Unknown Owner"}</span>
)
) : (
<span className="text-muted-foreground">
Available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</CardContent>
</Card>
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
{/* Draft Order Display - Only show during pre_draft and draft */}
{season &&
(season.status === "pre_draft" || season.status === "draft") &&
draftSlots.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{season.status === "pre_draft"
? "Upcoming draft order"
: "Current draft order"}
</CardDescription>
</div>
<Button asChild size="sm">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{draftSlots.map((slot: any, index: number) => {
const ownerName = slot.team.ownerId
? ownerMap[slot.team.ownerId]
: null;
return (
<div
key={slot.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{slot.team.name}
</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">
{ownerName}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{new Date(league.createdAt).toLocaleDateString()}
</p>
</div>
{season && (
<>
<div>
<p className="text-sm text-muted-foreground">
Season Status
</p>
<p className="font-medium capitalize">
{season.status.replace("_", " ")}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Teams Filled
</p>
<p className="font-medium">
{teamsWithOwners} of {teams.length}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Draft Order Set
</p>
<div className="flex items-center gap-2">
<p className="font-medium">
{isDraftOrderSet ? "Yes" : "No"}
</p>
{!isDraftOrderSet && isUserCommissioner && (
<Button size="sm" variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Order
</Link>
</Button>
)}
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">
Draft Rounds
</p>
<p className="font-medium">{season.draftRounds}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Sports Selected
</p>
<p className="font-medium">{sportsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Draft Board</p>
<Link
to={`/leagues/${league.id}/draft-board/${season.id}`}
className="text-blue-600 hover:underline font-medium"
>
View Draft Board
</Link>
</div>
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
to={`/leagues/${league.id}/standings/${season.id}`}
className="text-blue-600 hover:underline font-medium"
>
View Standings
</Link>
</div>
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)}
</p>
</div>
{season.draftDateTime && (
<div>
<p className="text-sm text-muted-foreground">
Draft Date
</p>
<p className="font-medium">
{format(new Date(season.draftDateTime), "PPP 'at' p")}
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Commissioners</CardTitle>
<CardDescription>
{commissioners.length} commissioner
{commissioners.length !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{commissioners.map((commissioner) => {
const commissionerName = commissionerMap[commissioner.userId];
const hasTeam = teams.some(
(t) => t.ownerId === commissioner.userId
);
return (
<div
key={commissioner.id}
className="py-2 border-b last:border-0"
>
<p className="font-medium">
{commissionerName || "Unknown User"}
{commissioner.userId === currentUserId && (
<span className="ml-2 text-xs text-primary">
(You)
</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground">
No team
</p>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}