* Fix circular dependency and client-bundle server-only code in simulator routes NCAAM and NCAAW simulators imported getParticipantSimulatorInputs from models/simulator, which imports manifest.ts, which imports registry.ts — creating a cycle back through registry.ts → simulator files. This caused a TDZ ReferenceError in the client bundle. Fix: inline a direct seasonParticipantSimulatorInputs query in both simulators (they already run direct DB queries) instead of going through the model layer, breaking the cycle. Also moves getSimulatorInputPolicy from the component body to the loader on the simulator setup page. The component calling it pulled input-policy.ts → manifest.ts → registry.ts → all simulator implementations into the client bundle, which contain Node.js-only code and caused "Error loading route module" in production. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Move registry calls from component to loader in admin.sports.$id SIMULATOR_TYPES and getSimulatorInfo were used directly in the component body to build the simulator type dropdown. This dragged registry.ts — which imports every simulator implementation — into the client bundle, causing a TDZ ReferenceError that broke any route co-loaded with it (observed as "Error loading route module" on admin events pages). Fix: pre-build simulatorOptions in the loader and pass them as plain serializable data. The registry imports remain for the action's validator but are now only reachable from server-only code. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
249 lines
8.7 KiB
TypeScript
249 lines
8.7 KiB
TypeScript
import { Form, Link, redirect } from "react-router";
|
|
import type { Route } from "./+types/admin.sports.$id";
|
|
|
|
import { logger } from "~/lib/logger";
|
|
import { findAllSports, findSportById, updateSport } from "~/models/sport";
|
|
import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
|
|
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Input } from "~/components/ui/input";
|
|
import { Label } from "~/components/ui/label";
|
|
import { Textarea } from "~/components/ui/textarea";
|
|
import { SportIconUploader } from "~/components/ui/SportIconUploader";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "~/components/ui/card";
|
|
import { SIMULATOR_TYPES, getSimulatorInfo } from "~/services/simulations/registry";
|
|
|
|
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `${data?.sport?.name ?? "Sport"} - Brackt Admin` }];
|
|
}
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sport = await findSportById(params.id);
|
|
|
|
if (!sport) {
|
|
throw new Response("Sport not found", { status: 404 });
|
|
}
|
|
|
|
const sports = await findAllSports();
|
|
|
|
const simulatorOptions = [...SIMULATOR_TYPES]
|
|
.toSorted((a, b) => {
|
|
const nameA = getSimulatorInfo(a)?.name ?? a;
|
|
const nameB = getSimulatorInfo(b)?.name ?? b;
|
|
return nameA.localeCompare(nameB);
|
|
})
|
|
.map((type) => ({ value: type, label: getSimulatorInfo(type)?.name ?? type }));
|
|
|
|
return {
|
|
sport,
|
|
simulatorOptions,
|
|
existingIconOptions: sports
|
|
.filter((option) => option.id !== sport.id && Boolean(option.iconUrl))
|
|
.map((option) => ({ id: option.id, name: option.name, iconUrl: option.iconUrl as string })),
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const name = formData.get("name");
|
|
const type = formData.get("type");
|
|
const slug = formData.get("slug");
|
|
const description = formData.get("description");
|
|
const simulatorType = formData.get("simulatorType");
|
|
const iconUrlValue = formData.get("iconUrl");
|
|
|
|
// Validation
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
return { error: "Sport name is required" };
|
|
}
|
|
|
|
if (type !== "team" && type !== "individual") {
|
|
return { error: "Sport type must be either 'team' or 'individual'" };
|
|
}
|
|
|
|
if (typeof slug !== "string" || !slug.trim()) {
|
|
return { error: "Slug is required" };
|
|
}
|
|
|
|
// Validate slug format (lowercase, hyphens only)
|
|
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
return { error: "Slug must contain only lowercase letters, numbers, and hyphens" };
|
|
}
|
|
|
|
const existingSport = await findSportById(params.id);
|
|
const iconUrl = parseSportIconUrlInput(iconUrlValue);
|
|
if (iconUrl === undefined) {
|
|
return { error: "Sport icon must be an uploaded SVG icon." };
|
|
}
|
|
|
|
type ValidSimulatorType = typeof SIMULATOR_TYPES[number];
|
|
const parsedSimulatorType: ValidSimulatorType | null =
|
|
typeof simulatorType === "string" && (SIMULATOR_TYPES as readonly string[]).includes(simulatorType)
|
|
? (simulatorType as ValidSimulatorType)
|
|
: null;
|
|
|
|
try {
|
|
const updatedSport = await updateSport(params.id, {
|
|
name: name.trim(),
|
|
type,
|
|
slug: slug.trim(),
|
|
description: typeof description === "string" ? description.trim() : null,
|
|
iconUrl,
|
|
simulatorType: parsedSimulatorType,
|
|
});
|
|
|
|
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
|
await deleteSportIconIfUnused({
|
|
iconUrl: existingSport.iconUrl,
|
|
excludeSportId: params.id,
|
|
reason: "replaced",
|
|
});
|
|
}
|
|
|
|
return redirect("/admin/sports");
|
|
} catch (error) {
|
|
logger.error("Error updating sport:", error);
|
|
if (iconUrl && iconUrl !== existingSport?.iconUrl) {
|
|
await deleteSportIconIfUnused({ iconUrl, excludeSportId: params.id, reason: "unused" });
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
|
|
return {
|
|
error: isUniqueViolation
|
|
? "A sport with that slug already exists. Please choose a different slug."
|
|
: `Failed to update sport: ${message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
|
|
const { sport, simulatorOptions, existingIconOptions } = loaderData;
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<div className="max-w-2xl">
|
|
<div className="mb-6">
|
|
<h1 className="text-3xl font-bold">Edit Sport</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Update the sport information
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Sport Details</CardTitle>
|
|
<CardDescription>
|
|
Modify the information for this sport
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Form method="post" className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Sport Name</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
type="text"
|
|
placeholder="e.g., NFL, NBA, Golf - Men's"
|
|
defaultValue={sport.name}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="type">Type</Label>
|
|
<select id="type" name="type" defaultValue={sport.type} required className={SELECT_CLASS}>
|
|
<option value="team">Team Sport</option>
|
|
<option value="individual">Individual Sport</option>
|
|
</select>
|
|
<p className="text-sm text-muted-foreground">
|
|
Team sports have teams, individual sports have players
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="slug">Slug</Label>
|
|
<Input
|
|
id="slug"
|
|
name="slug"
|
|
type="text"
|
|
placeholder="e.g., nfl, nba, golf-mens"
|
|
pattern="[a-z0-9-]+"
|
|
defaultValue={sport.slug}
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
URL-friendly identifier (lowercase, hyphens only)
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
|
|
<select
|
|
id="simulatorType"
|
|
name="simulatorType"
|
|
defaultValue={sport.simulatorType ?? "none"}
|
|
className={SELECT_CLASS}
|
|
>
|
|
<option value="none">No simulator</option>
|
|
{simulatorOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-sm text-muted-foreground">
|
|
Algorithm used when running EV simulations for this sport
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="description">Description (Optional)</Label>
|
|
<Textarea
|
|
id="description"
|
|
name="description"
|
|
placeholder="Brief description of the sport"
|
|
rows={3}
|
|
defaultValue={sport.description || ""}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>Sport Icon (Optional)</Label>
|
|
<SportIconUploader
|
|
uploadUrl="/api/upload-sport-icon"
|
|
initialIconUrl={sport.iconUrl}
|
|
sportName={sport.name}
|
|
existingIconOptions={existingIconOptions}
|
|
/>
|
|
</div>
|
|
|
|
{actionData?.error && (
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
{actionData.error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-4">
|
|
<Button type="submit" className="flex-1">
|
|
Update Sport
|
|
</Button>
|
|
<Button type="button" variant="outline" asChild>
|
|
<Link to="/admin/sports">Cancel</Link>
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|