Add Cloudinary sports icon uploads
This commit is contained in:
parent
caaffda236
commit
82ee5d228f
21 changed files with 6637 additions and 120 deletions
53
app/components/SportIcon.tsx
Normal file
53
app/components/SportIcon.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
app/components/__tests__/SportIcon.test.tsx
Normal file
24
app/components/__tests__/SportIcon.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
188
app/components/ui/SportIconUploader.tsx
Normal file
188
app/components/ui/SportIconUploader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
app/components/ui/__tests__/SportIconUploader.test.tsx
Normal file
82
app/components/ui/__tests__/SportIconUploader.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
45
app/lib/__tests__/sport-icon-url.test.ts
Normal file
45
app/lib/__tests__/sport-icon-url.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
34
app/lib/sport-icon-url.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
108
app/routes/__tests__/admin.sports.$id.test.ts
Normal file
108
app/routes/__tests__/admin.sports.$id.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
60
app/routes/__tests__/admin.sports.new.test.ts
Normal file
60
app/routes/__tests__/admin.sports.new.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
104
app/routes/__tests__/api.upload-sport-icon.test.ts
Normal file
104
app/routes/__tests__/api.upload-sport-icon.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
61
app/routes/api.upload-sport-icon.ts
Normal file
61
app/routes/api.upload-sport-icon.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
1
drizzle/0097_puzzling_spectrum.sql
Normal file
1
drizzle/0097_puzzling_spectrum.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "sports" ALTER COLUMN "icon_url" SET DATA TYPE varchar(512);
|
||||
5780
drizzle/meta/0097_snapshot.json
Normal file
5780
drizzle/meta/0097_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -680,6 +680,13 @@
|
|||
"when": 1778177368941,
|
||||
"tag": "0096_demonic_vulture",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 97,
|
||||
"version": "7",
|
||||
"when": 1778189247519,
|
||||
"tag": "0097_puzzling_spectrum",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue