2025-10-13 10:04:32 -07:00
|
|
|
import { Form, Link, redirect } from "react-router";
|
|
|
|
|
import type { Route } from "./+types/admin.sports.$id";
|
|
|
|
|
import { findSportById, updateSport } from "~/models/sport";
|
2025-10-13 10:30:47 -07:00
|
|
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
|
|
|
import { join } from "node:path";
|
2025-10-13 10:04:32 -07:00
|
|
|
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 {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from "~/components/ui/select";
|
|
|
|
|
|
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
|
|
|
const sport = await findSportById(params.id);
|
|
|
|
|
|
|
|
|
|
if (!sport) {
|
|
|
|
|
throw new Response("Sport not found", { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { sport };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
2026-03-09 15:34:31 -07:00
|
|
|
const simulatorType = formData.get("simulatorType");
|
2025-10-13 10:30:47 -07:00
|
|
|
const iconFile = formData.get("iconFile");
|
|
|
|
|
const keepExistingIcon = formData.get("keepExistingIcon");
|
2025-10-13 10:04:32 -07:00
|
|
|
|
|
|
|
|
// 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" };
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 10:30:47 -07:00
|
|
|
// Get existing sport to preserve icon if needed
|
|
|
|
|
const existingSport = await findSportById(params.id);
|
|
|
|
|
let iconUrl: string | null | undefined = existingSport?.iconUrl;
|
|
|
|
|
|
|
|
|
|
// Handle file upload
|
|
|
|
|
if (iconFile instanceof File && iconFile.size > 0) {
|
|
|
|
|
const fileExtension = iconFile.name.split(".").pop()?.toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Validate file type
|
|
|
|
|
if (!fileExtension || !["svg", "png", "jpg", "jpeg"].includes(fileExtension)) {
|
|
|
|
|
return { error: "Icon must be an SVG, PNG, or JPG file" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create filename using slug
|
|
|
|
|
const filename = `${slug.trim()}.${fileExtension}`;
|
|
|
|
|
iconUrl = filename;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Ensure directory exists
|
|
|
|
|
const iconsDir = join(process.cwd(), "public", "sports-icons");
|
|
|
|
|
await mkdir(iconsDir, { recursive: true });
|
|
|
|
|
|
|
|
|
|
// Save file
|
|
|
|
|
const filepath = join(iconsDir, filename);
|
|
|
|
|
const bytes = await iconFile.arrayBuffer();
|
|
|
|
|
const buffer = Buffer.from(bytes);
|
|
|
|
|
await writeFile(filepath, buffer);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error saving icon file:", error);
|
|
|
|
|
return { error: "Failed to save icon file" };
|
|
|
|
|
}
|
|
|
|
|
} else if (keepExistingIcon !== "true") {
|
|
|
|
|
// If no file uploaded and not keeping existing, set to null
|
|
|
|
|
iconUrl = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket"] as const;
|
|
|
|
|
type ValidSimulatorType = typeof validSimulatorTypes[number];
|
|
|
|
|
const parsedSimulatorType: ValidSimulatorType | null =
|
|
|
|
|
typeof simulatorType === "string" && validSimulatorTypes.includes(simulatorType as ValidSimulatorType)
|
|
|
|
|
? (simulatorType as ValidSimulatorType)
|
|
|
|
|
: null;
|
|
|
|
|
|
2025-10-13 10:04:32 -07:00
|
|
|
try {
|
|
|
|
|
await updateSport(params.id, {
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
type,
|
|
|
|
|
slug: slug.trim(),
|
|
|
|
|
description: typeof description === "string" ? description.trim() : null,
|
2025-10-13 10:30:47 -07:00
|
|
|
iconUrl,
|
2026-03-09 15:34:31 -07:00
|
|
|
simulatorType: parsedSimulatorType,
|
2025-10-13 10:04:32 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return redirect("/admin/sports");
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("Error updating sport:", error);
|
|
|
|
|
return { error: "Failed to update sport. The slug might already exist." };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
|
|
|
|
|
const { sport } = 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 name="type" defaultValue={sport.type} required>
|
|
|
|
|
<SelectTrigger id="type">
|
|
|
|
|
<SelectValue placeholder="Select sport type" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="team">Team Sport</SelectItem>
|
|
|
|
|
<SelectItem value="individual">Individual Sport</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</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>
|
|
|
|
|
|
2026-03-09 15:34:31 -07:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
|
|
|
|
|
<Select name="simulatorType" defaultValue={sport.simulatorType ?? "none"}>
|
|
|
|
|
<SelectTrigger id="simulatorType">
|
|
|
|
|
<SelectValue placeholder="No simulator" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="none">No simulator</SelectItem>
|
|
|
|
|
<SelectItem value="f1_standings">F1 Standings Model</SelectItem>
|
|
|
|
|
<SelectItem value="indycar_standings">IndyCar Standings Model</SelectItem>
|
|
|
|
|
<SelectItem value="golf_qualifying_points">Golf Qualifying Points Model</SelectItem>
|
|
|
|
|
<SelectItem value="playoff_bracket">Bracket Monte Carlo</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Algorithm used when running EV simulations for this sport
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-13 10:04:32 -07:00
|
|
|
<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>
|
|
|
|
|
|
2025-10-13 10:30:47 -07:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="iconFile">Icon File (Optional)</Label>
|
|
|
|
|
{sport.iconUrl && (
|
|
|
|
|
<div className="mb-2 p-3 bg-muted rounded-md">
|
|
|
|
|
<p className="text-sm font-medium mb-1">Current icon:</p>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<img
|
|
|
|
|
src={`/sports-icons/${sport.iconUrl}`}
|
|
|
|
|
alt={sport.name}
|
|
|
|
|
className="w-12 h-12 object-contain"
|
|
|
|
|
onError={(e) => {
|
|
|
|
|
e.currentTarget.style.display = 'none';
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<code className="text-xs bg-background px-2 py-1 rounded">{sport.iconUrl}</code>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<Input
|
|
|
|
|
id="iconFile"
|
|
|
|
|
name="iconFile"
|
|
|
|
|
type="file"
|
|
|
|
|
accept=".svg,.png,.jpg,.jpeg"
|
|
|
|
|
/>
|
|
|
|
|
<input type="hidden" name="keepExistingIcon" value={sport.iconUrl ? "true" : "false"} />
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Upload a new SVG, PNG, or JPG file to replace the current icon. Leave empty to keep the existing icon.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-10-13 10:04:32 -07:00
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|