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

296 lines
9.9 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { toast } from "sonner";
import {
findLeagueById,
findCurrentSeason,
findTeamsBySeasonId,
findCommissionersByLeagueId,
isUserLeagueMember,
isCommissioner,
findUserByClerkId,
} from "~/models";
import type { Route } from "./+types/$leagueId";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
export async function loader(args: Route.LoaderArgs) {
const { userId } = await getAuth(args);
const { params } = args;
const { leagueId } = params;
// Fetch league
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
// Fetch current season
const season = await findCurrentSeason(leagueId);
// Fetch commissioners
const commissioners = await findCommissionersByLeagueId(leagueId);
// Check if current user is a commissioner
const isUserCommissioner = userId ? await isCommissioner(leagueId, userId) : false;
// Check if user is a member (has a team in current season)
const isUserMember = userId ? await isUserLeagueMember(leagueId, userId) : false;
// Check access: user must be a commissioner, a member, or an admin
// If not logged in or not authorized, throw 403
if (!userId) {
throw new Response("You must be logged in to view this league", { status: 401 });
}
if (!isUserCommissioner && !isUserMember) {
throw new Response("You do not have access to this league", { status: 403 });
}
// Fetch teams for current season
const teams = season ? await findTeamsBySeasonId(season.id) : [];
// Fetch user data for team owners
const ownerIds = teams.map(t => t.ownerId).filter((id): id is string => id !== null);
const uniqueOwnerIds = [...new Set(ownerIds)];
const owners = await Promise.all(
uniqueOwnerIds.map(async (ownerId) => {
const user = await findUserByClerkId(ownerId);
return user ? { clerkId: ownerId, name: user.username || user.displayName } : null;
})
);
const ownerMap = new Map(
owners.filter((o): o is NonNullable<typeof o> => o !== null).map(o => [o.clerkId, o.name])
);
// Fetch user data for commissioners
const commissionerIds = commissioners.map(c => c.userId);
const commissionerUsers = await Promise.all(
commissionerIds.map(async (commissionerId) => {
const user = await findUserByClerkId(commissionerId);
return user ? { clerkId: commissionerId, name: user.username || user.displayName } : null;
})
);
const commissionerMap = new Map(
commissionerUsers.filter((c): c is NonNullable<typeof c> => c !== null).map(c => [c.clerkId, c.name])
);
// Count available teams
const availableTeamCount = teams.filter(t => !t.ownerId).length;
return {
league,
season,
teams,
commissioners,
currentUserId: userId,
isUserCommissioner,
ownerMap: Object.fromEntries(ownerMap),
commissionerMap: Object.fromEntries(commissionerMap),
availableTeamCount,
};
}
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const {
league,
season,
teams,
commissioners,
currentUserId,
isUserCommissioner,
ownerMap,
commissionerMap,
availableTeamCount,
} = loaderData;
const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (searchParams.get("updated") === "true") {
toast.success("League updated successfully");
setSearchParams({});
}
if (searchParams.get("joined") === "true") {
toast.success("Welcome to the league! You've been assigned a team.");
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 (err) {
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">
{isUserCommissioner && (
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings`}>Settings</Link>
</Button>
)}
<Button variant="outline" asChild>
<Link to="/">Back to Home</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-2 lg:grid-cols-3">
{isUserCommissioner && season && availableTeamCount > 0 && (
<Card className="md:col-span-2 lg:col-span-3">
<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 className="md:col-span-2 lg:col-span-3">
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-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>
<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>
)}
</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];
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>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</div>
);
}