Add Cloudinary sports icon uploads (#393)

This commit is contained in:
Chris Parsons 2026-05-07 16:07:34 -07:00 committed by GitHub
parent caaffda236
commit 20685bae6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 6637 additions and 120 deletions

View file

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { Trophy } from "lucide-react";
import { cn } from "~/lib/utils";
import { resolveSportIconUrl } from "~/lib/sport-icon-url";
interface SportIconProps {
sportName: string;
iconUrl?: string | null;
size?: "sm" | "md" | "lg" | "xl";
className?: string;
decorative?: boolean;
}
const sizeClasses = {
sm: "h-5 w-5",
md: "h-8 w-8",
lg: "h-12 w-12",
xl: "h-16 w-16",
};
export function SportIcon({
sportName,
iconUrl,
size = "md",
className,
decorative = false,
}: SportIconProps) {
const [failed, setFailed] = useState(false);
const resolvedIconUrl = resolveSportIconUrl(iconUrl);
useEffect(() => {
setFailed(false);
}, [resolvedIconUrl]);
if (resolvedIconUrl && !failed) {
return (
<img
src={resolvedIconUrl}
alt={decorative ? "" : sportName}
className={cn(sizeClasses[size], "shrink-0 object-contain", className)}
onError={() => setFailed(true)}
/>
);
}
return (
<Trophy
aria-hidden={decorative}
aria-label={decorative ? undefined : sportName}
className={cn(sizeClasses[size], "shrink-0 text-muted-foreground", className)}
/>
);
}

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { SportIcon } from "~/components/SportIcon";
describe("SportIcon", () => {
it("renders a resolved legacy icon URL", () => {
render(<SportIcon sportName="NFL" iconUrl="nfl.svg" />);
const icon = screen.getByRole("img", { name: "NFL" });
expect(icon).toHaveAttribute("src", "/sports-icons/nfl.svg");
});
it("renders decorative icons with empty alt text", () => {
const { container } = render(<SportIcon sportName="NBA" iconUrl="https://example.com/nba.svg" decorative />);
expect(container.querySelector("img")).toHaveAttribute("alt", "");
});
it("falls back to a trophy when no icon URL exists", () => {
render(<SportIcon sportName="Golf" />);
expect(screen.getByLabelText("Golf")).toBeInTheDocument();
});
});

View file

@ -11,11 +11,13 @@ import {
import { Trophy, Target, Flag, Calendar, Users } from "lucide-react";
import { formatEventDate } from "~/lib/date-utils";
import type { UpcomingParticipantEvent } from "~/models/scoring-event";
import { SportIcon } from "~/components/SportIcon";
interface SportSeasonCardProps {
leagueId: string;
sportSeasonId: string;
sportName: string;
sportIconUrl?: string | null;
seasonName: string;
status: "upcoming" | "active" | "completed";
scoringPattern?: string | null;
@ -66,6 +68,7 @@ export function SportSeasonCard({
leagueId,
sportSeasonId,
sportName,
sportIconUrl,
seasonName,
status,
scoringPattern,
@ -124,7 +127,10 @@ export function SportSeasonCard({
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg mb-1">{sportName}</CardTitle>
<CardTitle className="mb-1 flex items-center gap-2 text-lg">
<SportIcon sportName={sportName} iconUrl={sportIconUrl} size="sm" decorative />
<span className="truncate">{sportName}</span>
</CardTitle>
<CardDescription className="truncate">{seasonName}</CardDescription>
</div>
{getStatusBadge()}

View file

@ -0,0 +1,188 @@
import { useRef, useState } from "react";
import { ImageIcon, Trash2, Upload } from "lucide-react";
import { Button } from "~/components/ui/button";
import { SportIcon } from "~/components/SportIcon";
import { cn } from "~/lib/utils";
interface SportIconUploaderProps {
name?: string;
uploadUrl: string;
initialIconUrl?: string | null;
sportName?: string;
}
const MAX_UPLOAD_BYTES = 1024 * 1024;
const SVG_TYPE = "image/svg+xml";
export function SportIconUploader({
name = "iconUrl",
uploadUrl,
initialIconUrl = null,
sportName = "Sport icon",
}: SportIconUploaderProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [iconUrl, setIconUrl] = useState(initialIconUrl ?? "");
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [status, setStatus] = useState<"idle" | "uploading" | "uploaded" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
function prepareFile(file: File | undefined) {
setError(null);
setStatus("idle");
if (!file) return;
if (file.type !== SVG_TYPE && !file.name.toLowerCase().endsWith(".svg")) {
setError("Choose an SVG file.");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
setError("Choose an SVG under 1 MB.");
return;
}
setSelectedFile(file);
const reader = new FileReader();
reader.addEventListener("load", () => setPreviewUrl(String(reader.result)));
reader.readAsDataURL(file);
}
function clearSelectedFile() {
setSelectedFile(null);
setPreviewUrl(null);
setError(null);
setStatus("idle");
}
function removeIcon() {
setIconUrl("");
clearSelectedFile();
}
async function uploadSelectedFile() {
if (!selectedFile) {
setError("Choose an SVG first.");
return;
}
setStatus("uploading");
setError(null);
try {
const formData = new FormData();
formData.append("file", selectedFile);
const response = await fetch(uploadUrl, { method: "POST", body: formData });
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(typeof result.error === "string" ? result.error : "Upload failed.");
}
if (typeof result.iconUrl !== "string") {
throw new Error("Upload response did not include an icon URL.");
}
setIconUrl(result.iconUrl);
setSelectedFile(null);
setPreviewUrl(null);
setStatus("uploaded");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Upload failed.");
}
}
return (
<div className="space-y-4">
<input type="hidden" name={name} value={iconUrl} />
<input
ref={fileInputRef}
type="file"
accept=".svg,image/svg+xml"
onChange={(event) => {
prepareFile(event.target.files?.[0]);
event.target.value = "";
}}
className="sr-only"
/>
{iconUrl && !selectedFile && (
<div className="space-y-3 rounded-md border border-border bg-muted/40 p-4">
<div className="flex items-center gap-3">
<SportIcon sportName={sportName} iconUrl={iconUrl} size="lg" />
<div className="min-w-0">
<p className="text-sm font-medium">Current icon</p>
<p className="truncate text-xs text-muted-foreground">{iconUrl}</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-2 h-4 w-4" />
Replace SVG
</Button>
<Button type="button" variant="outline" onClick={removeIcon}>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
{!iconUrl && !selectedFile && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragEnter={(event) => {
event.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={(event) => {
event.preventDefault();
setIsDragging(false);
prepareFile(event.dataTransfer.files[0]);
}}
className={cn(
"flex min-h-40 w-full flex-col items-center justify-center rounded-lg border border-dashed bg-card/40 px-6 py-8 text-center transition-colors",
isDragging ? "border-[#adf661] bg-[#adf661]/10" : "border-border hover:border-muted-foreground hover:bg-accent/40"
)}
>
<ImageIcon className="mb-3 h-9 w-9 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Drop an SVG icon</span>
<span className="text-xs text-muted-foreground/80">or click to browse</span>
</button>
)}
{selectedFile && previewUrl && (
<div className="space-y-3 rounded-md border border-border bg-background p-4">
<div className="flex items-center gap-3">
<img src={previewUrl} alt="" className="h-12 w-12 object-contain" />
<div className="min-w-0">
<p className="text-sm font-medium">Ready to upload</p>
<p className="truncate text-xs text-muted-foreground">{selectedFile.name}</p>
</div>
</div>
<div className="flex gap-2">
<Button type="button" onClick={uploadSelectedFile} disabled={status === "uploading"}>
<Upload className="mr-2 h-4 w-4" />
{status === "uploading" ? "Uploading..." : "Upload SVG"}
</Button>
<Button type="button" variant="outline" onClick={clearSelectedFile}>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
</div>
</div>
)}
{status === "uploaded" && <p className="text-sm text-[#adf661]">Icon uploaded. Save the sport to apply it.</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-sm text-muted-foreground">SVG only, up to 1 MB. Existing local icons keep working until replaced.</p>
</div>
);
}

View file

@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { SportIconUploader } from "../SportIconUploader";
import type { FormEvent } from "react";
beforeEach(() => {
vi.restoreAllMocks();
});
describe("SportIconUploader", () => {
it("stores the initial icon URL in a hidden input", () => {
const { container } = render(
<SportIconUploader uploadUrl="/api/upload-sport-icon" initialIconUrl="nfl.svg" sportName="NFL" />
);
expect(container.querySelector('input[name="iconUrl"]')).toHaveValue("nfl.svg");
expect(screen.getByText("Current icon")).toBeInTheDocument();
});
it("rejects non-SVG files before upload", async () => {
const { container } = render(<SportIconUploader uploadUrl="/api/upload-sport-icon" />);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, {
target: { files: [new File(["not svg"], "icon.png", { type: "image/png" })] },
});
expect(await screen.findByText("Choose an SVG file.")).toBeInTheDocument();
});
it("uploads an SVG and updates the hidden input", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg" }),
})
);
const { container } = render(<SportIconUploader uploadUrl="/api/upload-sport-icon" />);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, {
target: { files: [new File(["<svg />"], "icon.svg", { type: "image/svg+xml" })] },
});
fireEvent.click(await screen.findByRole("button", { name: "Upload SVG" }));
await waitFor(() => {
expect(container.querySelector('input[name="iconUrl"]')).toHaveValue(
"https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg"
);
});
expect(screen.getByText("Icon uploaded. Save the sport to apply it.")).toBeInTheDocument();
});
it("does not submit a parent form when uploading an SVG", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg" }),
})
);
const onSubmit = vi.fn((event: FormEvent<HTMLFormElement>) => event.preventDefault());
const { container } = render(
<form onSubmit={onSubmit}>
<SportIconUploader uploadUrl="/api/upload-sport-icon" />
</form>
);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(fileInput, {
target: { files: [new File(["<svg />"], "icon.svg", { type: "image/svg+xml" })] },
});
fireEvent.click(await screen.findByRole("button", { name: "Upload SVG" }));
await waitFor(() => expect(global.fetch).toHaveBeenCalled());
expect(onSubmit).not.toHaveBeenCalled();
});
});

View file

@ -1,20 +1,21 @@
import { describe, expect, it } from "vitest";
import { cloudinaryPublicIdFromUrl } from "~/lib/cloudinary.server";
import { cloudinaryPublicIdFromUrl } from "../cloudinary.server";
describe("cloudinaryPublicIdFromUrl", () => {
it("extracts public IDs from regular delivery URLs", () => {
it("extracts avatar public IDs", () => {
expect(
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/v1234567890/avatars/user-1.jpg")
).toBe("avatars/user-1");
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/v123/avatars/team-1.jpg")
).toBe("avatars/team-1");
});
it("extracts public IDs from transformed delivery URLs", () => {
it("extracts sport icon public IDs", () => {
expect(
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/w_45,h_45,c_fill/v1234567890/avatars/team-2.png")
).toBe("avatars/team-2");
cloudinaryPublicIdFromUrl("https://res.cloudinary.com/demo/image/upload/v123/sports-icons/nfl.svg")
).toBe("sports-icons/nfl");
});
it("returns null for non-Cloudinary-like URLs", () => {
expect(cloudinaryPublicIdFromUrl("https://example.com/avatar.jpg")).toBeNull();
it("ignores non-managed URLs", () => {
expect(cloudinaryPublicIdFromUrl("/sports-icons/nfl.svg")).toBeNull();
expect(cloudinaryPublicIdFromUrl(null)).toBeNull();
});
});

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import {
isCloudinarySportIconUrl,
isLegacySportIconFilename,
parseSportIconUrlInput,
resolveSportIconUrl,
} from "../sport-icon-url";
describe("resolveSportIconUrl", () => {
it("returns null for empty values", () => {
expect(resolveSportIconUrl(null)).toBeNull();
expect(resolveSportIconUrl(undefined)).toBeNull();
});
it("keeps remote and absolute URLs unchanged", () => {
expect(resolveSportIconUrl("https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg")).toBe(
"https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg"
);
expect(resolveSportIconUrl("/custom/icon.svg")).toBe("/custom/icon.svg");
});
it("resolves legacy filenames from public sports-icons", () => {
expect(resolveSportIconUrl("nfl.svg")).toBe("/sports-icons/nfl.svg");
});
});
describe("sport icon URL validation", () => {
it("accepts Cloudinary sports icon SVG URLs", () => {
const url = "https://res.cloudinary.com/demo/image/upload/v123/sports-icons/nfl.svg";
expect(isCloudinarySportIconUrl(url)).toBe(true);
expect(parseSportIconUrlInput(url)).toBe(url);
});
it("accepts legacy SVG filenames", () => {
expect(isLegacySportIconFilename("nfl.svg")).toBe(true);
expect(parseSportIconUrlInput("nfl.svg")).toBe("nfl.svg");
});
it("rejects arbitrary URLs and non-SVG files", () => {
expect(parseSportIconUrlInput("https://example.com/icon.svg")).toBeUndefined();
expect(parseSportIconUrlInput("https://res.cloudinary.com/demo/image/upload/v123/avatars/nfl.svg")).toBeUndefined();
expect(parseSportIconUrlInput("nfl.png")).toBeUndefined();
expect(parseSportIconUrlInput("../nfl.svg")).toBeUndefined();
});
});

View file

@ -10,6 +10,11 @@ interface UploadAvatarImageArgs {
notificationUrl: string;
}
interface UploadSportIconArgs {
buffer: Buffer;
slug?: string | null;
}
function requireCloudinaryConfig() {
const cloudName = process.env.CLOUDINARY_CLOUD_NAME;
const apiKey = process.env.CLOUDINARY_API_KEY;
@ -55,10 +60,29 @@ export async function uploadAvatarImage({
});
}
function normalizePublicIdPart(value: string | null | undefined): string {
const normalized = value?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
return normalized || "sport";
}
export async function uploadSportIcon({ buffer, slug }: UploadSportIconArgs) {
requireCloudinaryConfig();
return await cloudinary.uploader.upload(toDataUri(buffer, "image/svg+xml"), {
resource_type: "image",
folder: "sports-icons",
public_id: `${normalizePublicIdPart(slug)}-${Date.now()}`,
allowed_formats: ["svg"],
overwrite: false,
});
}
export function cloudinaryPublicIdFromUrl(url: string | null | undefined): string | null {
if (!url) return null;
const match = url.match(/\/avatars\/([^/.]+)/);
return match ? `avatars/${match[1]}` : null;
if (!url.includes("res.cloudinary.com") || !url.includes("/image/upload/")) return null;
const path = url.split("?")[0];
const match = path.match(/\/(?:v\d+\/)?((?:avatars|sports-icons)\/[^.]+)/);
return match?.[1] ?? null;
}
export function buildWebhookUrl(request: Request): string {

34
app/lib/sport-icon-url.ts Normal file
View file

@ -0,0 +1,34 @@
const LEGACY_ICON_FILENAME_PATTERN = /^[a-z0-9][a-z0-9._-]*\.svg$/i;
const CLOUDINARY_SPORT_ICON_PATH_PATTERN = /\/image\/upload\/(?:v\d+\/)?sports-icons\/[^/?#]+\.svg(?:[?#].*)?$/i;
export function resolveSportIconUrl(iconUrl: string | null | undefined): string | null {
if (!iconUrl) return null;
if (/^(https?:|data:|\/)/.test(iconUrl)) return iconUrl;
return `/sports-icons/${iconUrl}`;
}
export function isCloudinarySportIconUrl(iconUrl: string): boolean {
try {
const url = new URL(iconUrl);
return url.protocol === "https:" &&
url.hostname.endsWith("res.cloudinary.com") &&
CLOUDINARY_SPORT_ICON_PATH_PATTERN.test(`${url.pathname}${url.search}${url.hash}`);
} catch {
return false;
}
}
export function isLegacySportIconFilename(iconUrl: string): boolean {
return LEGACY_ICON_FILENAME_PATTERN.test(iconUrl) && !iconUrl.includes("/");
}
export function parseSportIconUrlInput(value: unknown): string | null | undefined {
if (typeof value !== "string" || !value.trim()) return null;
const iconUrl = value.trim();
if (isCloudinarySportIconUrl(iconUrl) || isLegacySportIconFilename(iconUrl)) {
return iconUrl;
}
return undefined;
}

View file

@ -52,6 +52,7 @@ export default [
route("api/user/timezone", "routes/api/user.timezone.ts"),
route("api/upload-avatar", "routes/api.upload-avatar.ts"),
route("api/upload-team-logo", "routes/api.upload-team-logo.ts"),
route("api/upload-sport-icon", "routes/api.upload-sport-icon.ts"),
route("api/webhooks/cloudinary", "routes/api.webhooks.cloudinary.ts"),
route("api/seasons/:seasonId/draft", "routes/api/seasons.$seasonId.draft.ts"),
route("login", "routes/login.tsx"),

View file

@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/models/sport", () => ({
findSportById: vi.fn(),
updateSport: vi.fn(),
}));
vi.mock("~/lib/cloudinary.server", () => ({
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
vi.mock("~/services/simulations/registry", () => ({
SIMULATOR_TYPES: ["playoff_bracket"],
getSimulatorInfo: vi.fn((type: string) => ({ name: type })),
}));
import { action } from "../admin.sports.$id";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { findSportById, updateSport } from "~/models/sport";
function makeRequest(iconUrl: string) {
const formData = new FormData();
formData.set("name", "NFL");
formData.set("type", "team");
formData.set("slug", "nfl");
formData.set("description", "Football");
formData.set("simulatorType", "playoff_bracket");
formData.set("iconUrl", iconUrl);
return new Request("http://test/admin/sports/sport-1", {
method: "POST",
body: formData,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findSportById).mockResolvedValue({
id: "sport-1",
name: "Old NFL",
type: "team",
slug: "nfl",
description: null,
iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/old.svg",
simulatorType: null,
createdAt: new Date(),
updatedAt: new Date(),
} as never);
});
describe("admin sport edit action", () => {
it("stores the submitted icon URL and deletes a replaced Cloudinary icon", async () => {
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
vi.mocked(updateSport).mockResolvedValue({
iconUrl: nextIconUrl,
} as never);
await action({
request: makeRequest(nextIconUrl),
params: { id: "sport-1" },
context: {},
} as never);
expect(updateSport).toHaveBeenCalledWith("sport-1", expect.objectContaining({ iconUrl: nextIconUrl }));
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(
"https://res.cloudinary.com/demo/image/upload/v1/sports-icons/old.svg"
);
});
it("stores null when the icon is removed", async () => {
vi.mocked(updateSport).mockResolvedValue({
iconUrl: null,
} as never);
await action({
request: makeRequest(""),
params: { id: "sport-1" },
context: {},
} as never);
expect(updateSport).toHaveBeenCalledWith("sport-1", expect.objectContaining({ iconUrl: null }));
});
it("rejects arbitrary icon URLs", async () => {
const response = await action({
request: makeRequest("https://example.com/icon.svg"),
params: { id: "sport-1" },
context: {},
} as never);
expect(response).toEqual({ error: "Sport icon must be an uploaded SVG icon." });
expect(updateSport).not.toHaveBeenCalled();
});
it("deletes a newly uploaded icon when update fails", async () => {
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
vi.mocked(updateSport).mockRejectedValue(new Error("duplicate"));
await action({
request: makeRequest(nextIconUrl),
params: { id: "sport-1" },
context: {},
} as never);
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(nextIconUrl);
});
});

View file

@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/models/sport", () => ({
createSport: vi.fn(),
}));
vi.mock("~/lib/cloudinary.server", () => ({
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
import { action } from "../admin.sports.new";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
import { createSport } from "~/models/sport";
function makeRequest(iconUrl: string) {
const formData = new FormData();
formData.set("name", "NFL");
formData.set("type", "team");
formData.set("slug", "nfl");
formData.set("description", "Football");
formData.set("iconUrl", iconUrl);
return new Request("http://test/admin/sports/new", {
method: "POST",
body: formData,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(createSport).mockResolvedValue({} as never);
});
describe("admin sport create action", () => {
it("rejects arbitrary icon URLs", async () => {
const response = await action({
request: makeRequest("https://example.com/icon.svg"),
params: {},
context: {},
} as never);
expect(response).toEqual({ error: "Sport icon must be an uploaded SVG icon." });
expect(createSport).not.toHaveBeenCalled();
});
it("deletes a newly uploaded icon when create fails", async () => {
const iconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg";
vi.mocked(createSport).mockRejectedValue(new Error("duplicate"));
await action({
request: makeRequest(iconUrl),
params: {},
context: {},
} as never);
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(iconUrl);
});
});

View file

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("~/lib/auth.server", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}));
vi.mock("~/models/user", () => ({
isUserAdmin: vi.fn(),
}));
vi.mock("~/lib/cloudinary.server", () => ({
uploadSportIcon: vi.fn(),
}));
vi.mock("~/lib/logger", () => ({
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
}));
import { action } from "../api.upload-sport-icon";
import { auth } from "~/lib/auth.server";
import { uploadSportIcon } from "~/lib/cloudinary.server";
import { isUserAdmin } from "~/models/user";
function makeRequest(file?: File) {
const formData = new FormData();
if (file) formData.append("file", file);
return {
headers: new Headers(),
formData: async () => formData,
} as Request;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue({ user: { id: "admin-1" } } as never);
vi.mocked(isUserAdmin).mockResolvedValue(true as never);
vi.mocked(uploadSportIcon).mockResolvedValue({
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg",
} as never);
});
describe("upload sport icon action", () => {
it("requires a signed-in user", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
const response = await action({ request: makeRequest(), params: {}, context: {} } as never);
expect(response.status).toBe(401);
});
it("requires an admin user", async () => {
vi.mocked(isUserAdmin).mockResolvedValue(false as never);
const response = await action({ request: makeRequest(), params: {}, context: {} } as never);
expect(response.status).toBe(403);
});
it("rejects non-SVG files", async () => {
const response = await action({
request: makeRequest(new File(["hello"], "icon.png", { type: "image/png" })),
params: {},
context: {},
} as never);
expect(response.status).toBe(400);
expect(uploadSportIcon).not.toHaveBeenCalled();
});
it("rejects SVG files over 1 MB", async () => {
const response = await action({
request: makeRequest(new File([new Uint8Array(1024 * 1024 + 1)], "icon.svg", { type: "image/svg+xml" })),
params: {},
context: {},
} as never);
expect(response.status).toBe(400);
expect(uploadSportIcon).not.toHaveBeenCalled();
});
it("uploads valid SVG files to Cloudinary", async () => {
const file = new File(["<svg viewBox=\"0 0 1 1\" />"], "icon.svg", { type: "image/svg+xml" });
Object.defineProperty(file, "arrayBuffer", {
value: async () => Buffer.from("<svg viewBox=\"0 0 1 1\" />"),
});
const response = await action({
request: makeRequest(file),
params: {},
context: {},
} as never);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
iconUrl: "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg",
});
expect(uploadSportIcon).toHaveBeenCalledWith({
buffer: expect.any(Buffer),
slug: null,
});
});
});

View file

@ -3,12 +3,13 @@ import type { Route } from "./+types/admin.sports.$id";
import { logger } from "~/lib/logger";
import { findSportById, updateSport } from "~/models/sport";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.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,
@ -46,8 +47,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const slug = formData.get("slug");
const description = formData.get("description");
const simulatorType = formData.get("simulatorType");
const iconFile = formData.get("iconFile");
const keepExistingIcon = formData.get("keepExistingIcon");
const iconUrlValue = formData.get("iconUrl");
// Validation
if (typeof name !== "string" || !name.trim()) {
@ -67,40 +67,10 @@ 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) {
logger.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;
const iconUrl = parseSportIconUrlInput(iconUrlValue);
if (iconUrl === undefined) {
return { error: "Sport icon must be an uploaded SVG icon." };
}
type ValidSimulatorType = typeof SIMULATOR_TYPES[number];
@ -110,7 +80,7 @@ export async function action({ request, params }: Route.ActionArgs) {
: null;
try {
await updateSport(params.id, {
const updatedSport = await updateSport(params.id, {
name: name.trim(),
type,
slug: slug.trim(),
@ -119,9 +89,20 @@ export async function action({ request, params }: Route.ActionArgs) {
simulatorType: parsedSimulatorType,
});
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
deleteCloudinaryImageByUrl(existingSport.iconUrl).catch((error) => {
logger.error("Failed to delete replaced sport icon from Cloudinary:", error);
});
}
return redirect("/admin/sports");
} catch (error) {
logger.error("Error updating sport:", error);
if (iconUrl && iconUrl !== existingSport?.iconUrl) {
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => {
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
});
}
const message = error instanceof Error ? error.message : String(error);
const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
return {
@ -236,33 +217,12 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
</div>
<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"
<Label>Sport Icon (Optional)</Label>
<SportIconUploader
uploadUrl="/api/upload-sport-icon"
initialIconUrl={sport.iconUrl}
sportName={sport.name}
/>
<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>
{actionData?.error && (

View file

@ -3,12 +3,13 @@ import type { Route } from "./+types/admin.sports.new";
import { logger } from "~/lib/logger";
import { createSport } from "~/models/sport";
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.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,
@ -34,7 +35,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");
const iconUrlValue = formData.get("iconUrl");
// Validation
if (typeof name !== "string" || !name.trim()) {
@ -54,35 +55,9 @@ 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) {
logger.error("Error saving icon file:", error);
return { error: "Failed to save icon file" };
}
const iconUrl = parseSportIconUrlInput(iconUrlValue);
if (iconUrl === undefined) {
return { error: "Sport icon must be an uploaded SVG icon." };
}
try {
@ -97,6 +72,11 @@ export async function action({ request }: Route.ActionArgs) {
return redirect("/admin/sports");
} catch (error) {
logger.error("Error creating sport:", error);
if (iconUrl) {
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => {
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
});
}
return { error: "Failed to create sport. The slug might already exist." };
}
}
@ -174,16 +154,8 @@ export default function NewSport({ actionData }: Route.ComponentProps) {
</div>
<div className="space-y-2">
<Label htmlFor="iconFile">Icon File (Optional)</Label>
<Input
id="iconFile"
name="iconFile"
type="file"
accept=".svg,.png,.jpg,.jpeg"
/>
<p className="text-sm text-muted-foreground">
Upload an SVG, PNG, or JPG file. The file will be saved as <code className="bg-muted px-1 py-0.5 rounded">{`{slug}.{ext}`}</code>
</p>
<Label>Sport Icon (Optional)</Label>
<SportIconUploader uploadUrl="/api/upload-sport-icon" sportName="New sport" />
</div>
{actionData?.error && (

View file

@ -20,6 +20,7 @@ import {
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { SportIcon } from "~/components/SportIcon";
export function meta(): Route.MetaDescriptors {
return [{ title: "Sports - Brackt Admin" }];
@ -86,7 +87,12 @@ export default function AdminSports({ loaderData }: Route.ComponentProps) {
<TableBody>
{sports.map((sport) => (
<TableRow key={sport.id}>
<TableCell className="font-medium">{sport.name}</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<SportIcon sportName={sport.name} iconUrl={sport.iconUrl} size="sm" />
<span>{sport.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant={sport.type === "team" ? "default" : "secondary"}>
{sport.type}

View file

@ -0,0 +1,61 @@
import { auth } from "~/lib/auth.server";
import { uploadSportIcon } from "~/lib/cloudinary.server";
import { logger } from "~/lib/logger";
import { isUserAdmin } from "~/models/user";
import type { Route } from "./+types/api.upload-sport-icon";
const MAX_UPLOAD_BYTES = 1024 * 1024;
function isSvgFile(file: File): boolean {
return file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
}
function looksLikeSvg(buffer: Buffer): boolean {
return buffer.toString("utf8", 0, Math.min(buffer.length, 1024)).includes("<svg");
}
export async function action({ request }: Route.ActionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
const userId = session?.user.id ?? null;
if (!userId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (!(await isUserAdmin(userId))) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const formData = await request.formData();
const file = formData.get("file");
const slug = formData.get("slug");
if (!(file instanceof File)) {
return Response.json({ error: "SVG file is required" }, { status: 400 });
}
if (!isSvgFile(file)) {
return Response.json({ error: "Icon must be an SVG file" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return Response.json({ error: "Icon must be under 1 MB" }, { status: 400 });
}
try {
const buffer = Buffer.from(await file.arrayBuffer());
if (!looksLikeSvg(buffer)) {
return Response.json({ error: "Icon must be a valid SVG file" }, { status: 400 });
}
const result = await uploadSportIcon({
buffer,
slug: typeof slug === "string" ? slug : null,
});
return Response.json({ iconUrl: result.secure_url });
} catch (error) {
logger.error("Sport icon upload failed:", error);
return Response.json({ error: "Upload failed" }, { status: 500 });
}
}

View file

@ -350,7 +350,7 @@ export const sports = pgTable("sports", {
type: sportTypeEnum("type").notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
description: text("description"),
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
iconUrl: varchar("icon_url", { length: 512 }), // Cloudinary URL or legacy filename in /public/sports-icons/
simulatorType: simulatorTypeEnum("simulator_type"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),

View file

@ -0,0 +1 @@
ALTER TABLE "sports" ALTER COLUMN "icon_url" SET DATA TYPE varchar(512);

File diff suppressed because it is too large Load diff

View file

@ -680,6 +680,13 @@
"when": 1778177368941,
"tag": "0096_demonic_vulture",
"breakpoints": true
},
{
"idx": 97,
"version": "7",
"when": 1778189247519,
"tag": "0097_puzzling_spectrum",
"breakpoints": true
}
]
}