diff --git a/app/models/sport.ts b/app/models/sport.ts index d4444cd..05ab849 100644 --- a/app/models/sport.ts +++ b/app/models/sport.ts @@ -6,6 +6,10 @@ export type Sport = typeof schema.sports.$inferSelect; export type NewSport = typeof schema.sports.$inferInsert; export type SportType = "team" | "individual"; +export type SportWithActiveSeasons = Sport & { + activeSeasons: Array<{ id: string; name: string; year: number }>; +}; + export async function createSport(data: NewSport): Promise { const db = database(); const [sport] = await db @@ -36,6 +40,32 @@ export async function findAllSports(): Promise { }); } +export async function findAllSportsWithActiveSeasons(): Promise { + const db = database(); + const sports = await db.query.sports.findMany({ + orderBy: (sports, { asc }) => [asc(sports.name)], + with: { + sportsSeasons: { + where: (sportsSeasons, { eq, or }) => + or( + eq(sportsSeasons.status, "active"), + eq(sportsSeasons.status, "upcoming") + ), + orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], + }, + }, + }); + + return sports.map((sport) => ({ + ...sport, + activeSeasons: sport.sportsSeasons.map((season) => ({ + id: season.id, + name: season.name, + year: season.year, + })), + })); +} + export async function findSportsByType(type: SportType): Promise { const db = database(); return await db.query.sports.findMany({ diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index defb811..edbc55e 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -37,6 +37,21 @@ export async function findSportsSeasonsBySportId(sportId: string): Promise { + const db = database(); + return await db.query.sportsSeasons.findMany({ + where: (sportsSeasons, { eq, and }) => + and( + eq(sportsSeasons.sportId, sportId), + eq(sportsSeasons.status, "active") + ), + orderBy: (sportsSeasons, { desc }) => [desc(sportsSeasons.year)], + with: { + sport: true, + }, + }); +} + export async function findSportsSeasonsByYear(year: number): Promise { const db = database(); return await db.query.sportsSeasons.findMany({ diff --git a/app/routes/admin.sports.$id.tsx b/app/routes/admin.sports.$id.tsx index 90f28a5..86b28ba 100644 --- a/app/routes/admin.sports.$id.tsx +++ b/app/routes/admin.sports.$id.tsx @@ -1,6 +1,8 @@ import { Form, Link, redirect } from "react-router"; import type { Route } from "./+types/admin.sports.$id"; import { findSportById, updateSport } from "~/models/sport"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; @@ -36,6 +38,8 @@ export async function action({ request, params }: Route.ActionArgs) { const type = formData.get("type"); const slug = formData.get("slug"); const description = formData.get("description"); + const iconFile = formData.get("iconFile"); + const keepExistingIcon = formData.get("keepExistingIcon"); // Validation if (typeof name !== "string" || !name.trim()) { @@ -55,12 +59,49 @@ export async function action({ request, params }: Route.ActionArgs) { return { error: "Slug must contain only lowercase letters, numbers, and hyphens" }; } + // 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; + } + try { await updateSport(params.id, { name: name.trim(), type, slug: slug.trim(), description: typeof description === "string" ? description.trim() : null, + iconUrl, }); return redirect("/admin/sports"); @@ -147,6 +188,36 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro /> +
+ + {sport.iconUrl && ( +
+

Current icon:

+
+ {sport.name} { + e.currentTarget.style.display = 'none'; + }} + /> + {sport.iconUrl} +
+
+ )} + + +

+ Upload a new SVG, PNG, or JPG file to replace the current icon. Leave empty to keep the existing icon. +

+
+ {actionData?.error && (
{actionData.error} diff --git a/app/routes/admin.sports.new.tsx b/app/routes/admin.sports.new.tsx index 2a52a48..c9188d5 100644 --- a/app/routes/admin.sports.new.tsx +++ b/app/routes/admin.sports.new.tsx @@ -1,6 +1,8 @@ import { Form, Link, redirect } from "react-router"; import type { Route } from "./+types/admin.sports.new"; import { createSport } from "~/models/sport"; +import { writeFile, mkdir } from "node:fs/promises"; +import { join } from "node:path"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; @@ -26,6 +28,7 @@ export async function action({ request }: Route.ActionArgs) { const type = formData.get("type"); const slug = formData.get("slug"); const description = formData.get("description"); + const iconFile = formData.get("iconFile"); // Validation if (typeof name !== "string" || !name.trim()) { @@ -45,12 +48,44 @@ export async function action({ request }: Route.ActionArgs) { return { error: "Slug must contain only lowercase letters, numbers, and hyphens" }; } + let iconUrl: string | null = null; + + // 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" }; + } + } + try { await createSport({ name: name.trim(), type, slug: slug.trim(), description: typeof description === "string" ? description.trim() : null, + iconUrl, }); return redirect("/admin/sports"); @@ -132,6 +167,19 @@ export default function NewSport({ actionData }: Route.ComponentProps) { />
+
+ + +

+ Upload an SVG, PNG, or JPG file. The file will be saved as {`{slug}.{ext}`} +

+
+ {actionData?.error && (
{actionData.error} diff --git a/app/routes/admin.sports.tsx b/app/routes/admin.sports.tsx index fee245b..662d3c5 100644 --- a/app/routes/admin.sports.tsx +++ b/app/routes/admin.sports.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; import type { Route } from "./+types/admin.sports"; -import { findAllSports } from "~/models/sport"; +import { findAllSportsWithActiveSeasons } from "~/models/sport"; import { Card, CardContent, @@ -21,7 +21,7 @@ import { import { Badge } from "~/components/ui/badge"; export async function loader() { - const sports = await findAllSports(); + const sports = await findAllSportsWithActiveSeasons(); return { sports }; } @@ -74,6 +74,7 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) { Name Type Slug + Current Seasons Actions @@ -89,6 +90,25 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) { {sport.slug} + + {sport.activeSeasons.length > 0 ? ( +
+ {sport.activeSeasons.map((season) => ( + + {season.name} ({season.year}) + + ))} +
+ ) : ( + + No current seasons + + )} +