Harden sport icon cleanup
This commit is contained in:
parent
79092f0409
commit
7a3616e12c
9 changed files with 204 additions and 22 deletions
|
|
@ -1,14 +1,21 @@
|
||||||
import { useRef, useState } from "react";
|
import { useId, useRef, useState } from "react";
|
||||||
import { ImageIcon, Trash2, Upload } from "lucide-react";
|
import { ImageIcon, Trash2, Upload } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { SportIcon } from "~/components/SportIcon";
|
import { SportIcon } from "~/components/SportIcon";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
interface SportIconOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface SportIconUploaderProps {
|
interface SportIconUploaderProps {
|
||||||
name?: string;
|
name?: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
initialIconUrl?: string | null;
|
initialIconUrl?: string | null;
|
||||||
sportName?: string;
|
sportName?: string;
|
||||||
|
existingIconOptions?: SportIconOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 1024 * 1024;
|
||||||
|
|
@ -19,7 +26,9 @@ export function SportIconUploader({
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
initialIconUrl = null,
|
initialIconUrl = null,
|
||||||
sportName = "Sport icon",
|
sportName = "Sport icon",
|
||||||
|
existingIconOptions = [],
|
||||||
}: SportIconUploaderProps) {
|
}: SportIconUploaderProps) {
|
||||||
|
const selectId = useId();
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [iconUrl, setIconUrl] = useState(initialIconUrl ?? "");
|
const [iconUrl, setIconUrl] = useState(initialIconUrl ?? "");
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
|
@ -60,6 +69,11 @@ export function SportIconUploader({
|
||||||
clearSelectedFile();
|
clearSelectedFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function chooseExistingIcon(nextIconUrl: string) {
|
||||||
|
setIconUrl(nextIconUrl);
|
||||||
|
clearSelectedFile();
|
||||||
|
}
|
||||||
|
|
||||||
async function uploadSelectedFile() {
|
async function uploadSelectedFile() {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
setError("Choose an SVG first.");
|
setError("Choose an SVG first.");
|
||||||
|
|
@ -107,6 +121,30 @@ export function SportIconUploader({
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{existingIconOptions.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor={selectId} className="text-sm font-medium">
|
||||||
|
Use an existing sport icon
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
value={existingIconOptions.some((option) => option.iconUrl === iconUrl) ? iconUrl : ""}
|
||||||
|
onChange={(event) => chooseExistingIcon(event.target.value)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value="">Choose an icon from another sport...</option>
|
||||||
|
{existingIconOptions.map((option) => (
|
||||||
|
<option key={option.id} value={option.iconUrl}>
|
||||||
|
{option.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Selecting a sport reuses its current icon URL; no upload required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{iconUrl && !selectedFile && (
|
{iconUrl && !selectedFile && (
|
||||||
<div className="space-y-3 rounded-md border border-border bg-muted/40 p-4">
|
<div className="space-y-3 rounded-md border border-border bg-muted/40 p-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,25 @@ describe("SportIconUploader", () => {
|
||||||
expect(await screen.findByText("Choose an SVG file.")).toBeInTheDocument();
|
expect(await screen.findByText("Choose an SVG file.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("chooses an icon from an existing sport", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SportIconUploader
|
||||||
|
uploadUrl="/api/upload-sport-icon"
|
||||||
|
existingIconOptions={[
|
||||||
|
{ id: "sport-1", name: "NFL", iconUrl: "nfl.svg" },
|
||||||
|
{ id: "sport-2", name: "NBA", iconUrl: "nba.svg" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("combobox", { name: "Use an existing sport icon" }), {
|
||||||
|
target: { value: "nba.svg" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('input[name="iconUrl"]')).toHaveValue("nba.svg");
|
||||||
|
expect(screen.getByText("Current icon")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("uploads an SVG and updates the hidden input", async () => {
|
it("uploads an SVG and updates the hidden input", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,6 @@ describe("sport icon URL validation", () => {
|
||||||
expect(parseSportIconUrlInput("https://res.cloudinary.com/demo/image/upload/v123/avatars/nfl.svg")).toBeUndefined();
|
expect(parseSportIconUrlInput("https://res.cloudinary.com/demo/image/upload/v123/avatars/nfl.svg")).toBeUndefined();
|
||||||
expect(parseSportIconUrlInput("nfl.png")).toBeUndefined();
|
expect(parseSportIconUrlInput("nfl.png")).toBeUndefined();
|
||||||
expect(parseSportIconUrlInput("../nfl.svg")).toBeUndefined();
|
expect(parseSportIconUrlInput("../nfl.svg")).toBeUndefined();
|
||||||
|
expect(parseSportIconUrlInput("/sports-icons/nfl.svg")).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
28
app/lib/sport-icon-cleanup.server.ts
Normal file
28
app/lib/sport-icon-cleanup.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
|
import { logger } from "~/lib/logger";
|
||||||
|
import { isSportIconUrlUsed } from "~/models/sport";
|
||||||
|
|
||||||
|
interface DeleteSportIconIfUnusedArgs {
|
||||||
|
iconUrl: string;
|
||||||
|
excludeSportId?: string;
|
||||||
|
reason: "replaced" | "unused";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSportIconIfUnused({
|
||||||
|
iconUrl,
|
||||||
|
excludeSportId,
|
||||||
|
reason,
|
||||||
|
}: DeleteSportIconIfUnusedArgs): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (await isSportIconUrlUsed(iconUrl, excludeSportId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to check whether sport icon is reused; skipping Cloudinary cleanup:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCloudinaryImageByUrl(iconUrl).catch((error) => {
|
||||||
|
logger.error(`Failed to delete ${reason} sport icon from Cloudinary:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { and, eq, ne, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -48,6 +48,17 @@ export async function findAllSports(): Promise<Sport[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function isSportIconUrlUsed(iconUrl: string, excludeSportId?: string): Promise<boolean> {
|
||||||
|
const db = database();
|
||||||
|
const sport = await db.query.sports.findFirst({
|
||||||
|
where: excludeSportId
|
||||||
|
? and(eq(schema.sports.iconUrl, iconUrl), ne(schema.sports.id, excludeSportId))
|
||||||
|
: eq(schema.sports.iconUrl, iconUrl),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
return Boolean(sport);
|
||||||
|
}
|
||||||
|
|
||||||
export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> {
|
export async function findAllSportsWithActiveSeasons(): Promise<SportWithActiveSeasons[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
const sports = await db.query.sports.findMany({
|
const sports = await db.query.sports.findMany({
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("~/models/sport", () => ({
|
vi.mock("~/models/sport", () => ({
|
||||||
|
findAllSports: vi.fn(),
|
||||||
findSportById: vi.fn(),
|
findSportById: vi.fn(),
|
||||||
|
isSportIconUrlUsed: vi.fn(),
|
||||||
updateSport: vi.fn(),
|
updateSport: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/cloudinary.server", () => ({
|
vi.mock("~/lib/cloudinary.server", () => ({
|
||||||
|
|
@ -17,10 +19,10 @@ vi.mock("~/services/simulations/registry", () => ({
|
||||||
|
|
||||||
import { action } from "../admin.sports.$id";
|
import { action } from "../admin.sports.$id";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
import { findSportById, updateSport } from "~/models/sport";
|
import { findSportById, isSportIconUrlUsed, updateSport } from "~/models/sport";
|
||||||
|
|
||||||
function makeRequest(iconUrl: string) {
|
function makeRequest(iconUrl: string) {
|
||||||
const formData = new FormData();
|
const formData = new URLSearchParams();
|
||||||
formData.set("name", "NFL");
|
formData.set("name", "NFL");
|
||||||
formData.set("type", "team");
|
formData.set("type", "team");
|
||||||
formData.set("slug", "nfl");
|
formData.set("slug", "nfl");
|
||||||
|
|
@ -30,12 +32,14 @@ function makeRequest(iconUrl: string) {
|
||||||
|
|
||||||
return new Request("http://test/admin/sports/sport-1", {
|
return new Request("http://test/admin/sports/sport-1", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockResolvedValue(false);
|
||||||
vi.mocked(findSportById).mockResolvedValue({
|
vi.mocked(findSportById).mockResolvedValue({
|
||||||
id: "sport-1",
|
id: "sport-1",
|
||||||
name: "Old NFL",
|
name: "Old NFL",
|
||||||
|
|
@ -105,4 +109,33 @@ describe("admin sport edit action", () => {
|
||||||
|
|
||||||
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(nextIconUrl);
|
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(nextIconUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not delete a reused existing 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"));
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await action({
|
||||||
|
request: makeRequest(nextIconUrl),
|
||||||
|
params: { id: "sport-1" },
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the update error when icon reuse lookup fails during cleanup", async () => {
|
||||||
|
const nextIconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/new.svg";
|
||||||
|
vi.mocked(updateSport).mockRejectedValue(new Error("database unavailable"));
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockRejectedValue(new Error("database unavailable"));
|
||||||
|
|
||||||
|
const response = await action({
|
||||||
|
request: makeRequest(nextIconUrl),
|
||||||
|
params: { id: "sport-1" },
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(response).toEqual({ error: "Failed to update sport: database unavailable" });
|
||||||
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("~/models/sport", () => ({
|
vi.mock("~/models/sport", () => ({
|
||||||
createSport: vi.fn(),
|
createSport: vi.fn(),
|
||||||
|
findAllSports: vi.fn(),
|
||||||
|
isSportIconUrlUsed: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("~/lib/cloudinary.server", () => ({
|
vi.mock("~/lib/cloudinary.server", () => ({
|
||||||
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
|
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|
@ -12,10 +14,10 @@ vi.mock("~/lib/logger", () => ({
|
||||||
|
|
||||||
import { action } from "../admin.sports.new";
|
import { action } from "../admin.sports.new";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
||||||
import { createSport } from "~/models/sport";
|
import { createSport, isSportIconUrlUsed } from "~/models/sport";
|
||||||
|
|
||||||
function makeRequest(iconUrl: string) {
|
function makeRequest(iconUrl: string) {
|
||||||
const formData = new FormData();
|
const formData = new URLSearchParams();
|
||||||
formData.set("name", "NFL");
|
formData.set("name", "NFL");
|
||||||
formData.set("type", "team");
|
formData.set("type", "team");
|
||||||
formData.set("slug", "nfl");
|
formData.set("slug", "nfl");
|
||||||
|
|
@ -24,6 +26,7 @@ function makeRequest(iconUrl: string) {
|
||||||
|
|
||||||
return new Request("http://test/admin/sports/new", {
|
return new Request("http://test/admin/sports/new", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +34,7 @@ function makeRequest(iconUrl: string) {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(createSport).mockResolvedValue({} as never);
|
vi.mocked(createSport).mockResolvedValue({} as never);
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockResolvedValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("admin sport create action", () => {
|
describe("admin sport create action", () => {
|
||||||
|
|
@ -57,4 +61,33 @@ describe("admin sport create action", () => {
|
||||||
|
|
||||||
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(iconUrl);
|
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(iconUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not delete a reused existing 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"));
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockResolvedValue(true);
|
||||||
|
|
||||||
|
await action({
|
||||||
|
request: makeRequest(iconUrl),
|
||||||
|
params: {},
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the create error when icon reuse lookup fails during cleanup", async () => {
|
||||||
|
const iconUrl = "https://res.cloudinary.com/demo/image/upload/v1/sports-icons/nfl.svg";
|
||||||
|
vi.mocked(createSport).mockRejectedValue(new Error("database unavailable"));
|
||||||
|
vi.mocked(isSportIconUrlUsed).mockRejectedValue(new Error("database unavailable"));
|
||||||
|
|
||||||
|
const response = await action({
|
||||||
|
request: makeRequest(iconUrl),
|
||||||
|
params: {},
|
||||||
|
context: {},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(response).toEqual({ error: "Failed to create sport. The slug might already exist." });
|
||||||
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { Form, Link, redirect } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports.$id";
|
import type { Route } from "./+types/admin.sports.$id";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { findSportById, updateSport } from "~/models/sport";
|
import { findAllSports, findSportById, updateSport } from "~/models/sport";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
|
||||||
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -37,7 +37,14 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
throw new Response("Sport not found", { status: 404 });
|
throw new Response("Sport not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { sport };
|
const sports = await findAllSports();
|
||||||
|
|
||||||
|
return {
|
||||||
|
sport,
|
||||||
|
existingIconOptions: sports
|
||||||
|
.filter((option) => option.id !== sport.id && Boolean(option.iconUrl))
|
||||||
|
.map((option) => ({ id: option.id, name: option.name, iconUrl: option.iconUrl as string })),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
@ -90,8 +97,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
if (existingSport?.iconUrl && existingSport.iconUrl !== updatedSport.iconUrl) {
|
||||||
deleteCloudinaryImageByUrl(existingSport.iconUrl).catch((error) => {
|
await deleteSportIconIfUnused({
|
||||||
logger.error("Failed to delete replaced sport icon from Cloudinary:", error);
|
iconUrl: existingSport.iconUrl,
|
||||||
|
excludeSportId: params.id,
|
||||||
|
reason: "replaced",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,9 +108,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error updating sport:", error);
|
logger.error("Error updating sport:", error);
|
||||||
if (iconUrl && iconUrl !== existingSport?.iconUrl) {
|
if (iconUrl && iconUrl !== existingSport?.iconUrl) {
|
||||||
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => {
|
await deleteSportIconIfUnused({ iconUrl, excludeSportId: params.id, reason: "unused" });
|
||||||
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
|
const isUniqueViolation = message.includes("unique") || message.includes("duplicate");
|
||||||
|
|
@ -114,7 +121,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
|
export default function EditSport({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sport } = loaderData;
|
const { sport, existingIconOptions } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
|
|
@ -222,6 +229,7 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
||||||
uploadUrl="/api/upload-sport-icon"
|
uploadUrl="/api/upload-sport-icon"
|
||||||
initialIconUrl={sport.iconUrl}
|
initialIconUrl={sport.iconUrl}
|
||||||
sportName={sport.name}
|
sportName={sport.name}
|
||||||
|
existingIconOptions={existingIconOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { Form, Link, redirect } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports.new";
|
import type { Route } from "./+types/admin.sports.new";
|
||||||
|
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { createSport } from "~/models/sport";
|
import { createSport, findAllSports } from "~/models/sport";
|
||||||
import { deleteCloudinaryImageByUrl } from "~/lib/cloudinary.server";
|
import { deleteSportIconIfUnused } from "~/lib/sport-icon-cleanup.server";
|
||||||
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
import { parseSportIconUrlInput } from "~/lib/sport-icon-url";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
|
|
@ -29,6 +29,15 @@ export function meta(): Route.MetaDescriptors {
|
||||||
return [{ title: "New Sport - Brackt Admin" }];
|
return [{ title: "New Sport - Brackt Admin" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const sports = await findAllSports();
|
||||||
|
return {
|
||||||
|
existingIconOptions: sports
|
||||||
|
.filter((sport) => Boolean(sport.iconUrl))
|
||||||
|
.map((sport) => ({ id: sport.id, name: sport.name, iconUrl: sport.iconUrl as string })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
|
|
@ -73,15 +82,13 @@ export async function action({ request }: Route.ActionArgs) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error creating sport:", error);
|
logger.error("Error creating sport:", error);
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
deleteCloudinaryImageByUrl(iconUrl).catch((deleteError) => {
|
await deleteSportIconIfUnused({ iconUrl, reason: "unused" });
|
||||||
logger.error("Failed to delete unused sport icon from Cloudinary:", deleteError);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return { error: "Failed to create sport. The slug might already exist." };
|
return { error: "Failed to create sport. The slug might already exist." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewSport({ actionData }: Route.ComponentProps) {
|
export default function NewSport({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
|
|
@ -155,7 +162,11 @@ export default function NewSport({ actionData }: Route.ComponentProps) {
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Sport Icon (Optional)</Label>
|
<Label>Sport Icon (Optional)</Label>
|
||||||
<SportIconUploader uploadUrl="/api/upload-sport-icon" sportName="New sport" />
|
<SportIconUploader
|
||||||
|
uploadUrl="/api/upload-sport-icon"
|
||||||
|
sportName="New sport"
|
||||||
|
existingIconOptions={loaderData.existingIconOptions}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{actionData?.error && (
|
{actionData?.error && (
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue