diff --git a/app/features/img-upload/ImageRepository.server.ts b/app/features/img-upload/ImageRepository.server.ts index f65b4c7c5..0ddf6dfec 100644 --- a/app/features/img-upload/ImageRepository.server.ts +++ b/app/features/img-upload/ImageRepository.server.ts @@ -12,3 +12,13 @@ export function findById(id: number) { .where("UnvalidatedUserSubmittedImage.id", "=", id) .executeTakeFirst(); } + +export function deleteImageById(id: number) { + return db.transaction().execute(async (trx) => { + await trx.deleteFrom("Art").where("Art.imgId", "=", id).execute(); + await trx + .deleteFrom("UnvalidatedUserSubmittedImage") + .where("id", "=", id) + .execute(); + }); +} diff --git a/app/features/img-upload/actions/upload.admin.server.ts b/app/features/img-upload/actions/upload.admin.server.ts index ce38f99e7..78174c11c 100644 --- a/app/features/img-upload/actions/upload.admin.server.ts +++ b/app/features/img-upload/actions/upload.admin.server.ts @@ -6,7 +6,9 @@ import { badRequestIfFalsy, errorToastIfFalsy, parseRequestPayload, + successToast, } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; import * as ImageRepository from "../ImageRepository.server"; import { validateImage } from "../queries/validateImage"; import { validateImageSchema } from "../upload-schemas.server"; @@ -20,12 +22,29 @@ export const action: ActionFunction = async ({ request }) => { errorToastIfFalsy(isMod(user), "Only admins can validate images"); - const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId)); + switch (data._action) { + case "VALIDATE": { + for (const imageId of data.imageIds) { + const image = badRequestIfFalsy( + await ImageRepository.findById(imageId), + ); - validateImage(data.imageId); + validateImage(imageId); - if (image.tournamentId) { - clearTournamentDataCache(image.tournamentId); + if (image.tournamentId) { + clearTournamentDataCache(imageId); + } + } + break; + } + case "REJECT": { + await ImageRepository.deleteImageById(data.imageId); + + return successToast("The image was deleted"); + } + default: { + assertUnreachable(data); + } } return null; diff --git a/app/features/img-upload/loaders/upload.admin.server.ts b/app/features/img-upload/loaders/upload.admin.server.ts index 35ea48652..075acebb1 100644 --- a/app/features/img-upload/loaders/upload.admin.server.ts +++ b/app/features/img-upload/loaders/upload.admin.server.ts @@ -3,7 +3,7 @@ import { requireUserId } from "~/features/auth/core/user.server"; import { isMod } from "~/permissions"; import { notFoundIfFalsy } from "~/utils/remix.server"; import { countAllUnvalidatedImg } from "../queries/countAllUnvalidatedImg.server"; -import { oneUnvalidatedImage } from "../queries/oneUnvalidatedImage"; +import { unvalidatedImages } from "../queries/unvalidatedImages"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUserId(request); @@ -11,7 +11,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { notFoundIfFalsy(isMod(user)); return { - image: oneUnvalidatedImage(), + images: unvalidatedImages(), unvalidatedImgCount: countAllUnvalidatedImg(), }; }; diff --git a/app/features/img-upload/queries/oneUnvalidatedImage.ts b/app/features/img-upload/queries/unvalidatedImages.ts similarity index 67% rename from app/features/img-upload/queries/oneUnvalidatedImage.ts rename to app/features/img-upload/queries/unvalidatedImages.ts index 69ae5bb04..bf64a3677 100644 --- a/app/features/img-upload/queries/oneUnvalidatedImage.ts +++ b/app/features/img-upload/queries/unvalidatedImages.ts @@ -1,12 +1,16 @@ import { sql } from "~/db/sql"; import type { Tables } from "~/db/tables"; +import { IMAGES_TO_VALIDATE_AT_ONCE } from "../upload-constants"; const stm = sql.prepare(/* sql */ ` select "UnvalidatedUserSubmittedImage"."id", "UnvalidatedUserSubmittedImage"."url", - "UnvalidatedUserSubmittedImage"."submitterUserId" + "UnvalidatedUserSubmittedImage"."submitterUserId", + "User"."username" from "UnvalidatedUserSubmittedImage" + left join "User" on + "UnvalidatedUserSubmittedImage"."submitterUserId" = "User"."id" left join "Team" on "UnvalidatedUserSubmittedImage"."id" = "Team"."avatarImgId" or "UnvalidatedUserSubmittedImage"."id" = "Team"."bannerImgId" @@ -16,14 +20,16 @@ const stm = sql.prepare(/* sql */ ` "UnvalidatedUserSubmittedImage"."id" = "CalendarEvent"."avatarImgId" where "UnvalidatedUserSubmittedImage"."validatedAt" is null and ("Team"."id" is not null or "Art"."id" is not null or "CalendarEvent"."id" is not null) - limit 1 + limit ${IMAGES_TO_VALIDATE_AT_ONCE} `); type UnvalidatedImage = Pick< Tables["UserSubmittedImage"], "id" | "url" | "submitterUserId" ->; +> & { + username: Tables["User"]["username"]; +}; -export function oneUnvalidatedImage() { - return stm.get() as UnvalidatedImage | null; +export function unvalidatedImages() { + return stm.all() as Array; } diff --git a/app/features/img-upload/routes/upload.admin.tsx b/app/features/img-upload/routes/upload.admin.tsx index 698f8410e..0836b311c 100644 --- a/app/features/img-upload/routes/upload.admin.tsx +++ b/app/features/img-upload/routes/upload.admin.tsx @@ -1,6 +1,10 @@ -import { Form, useLoaderData } from "@remix-run/react"; +import { Form, Link, useLoaderData } from "@remix-run/react"; +import * as React from "react"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; +import { SendouButton } from "~/components/elements/Button"; +import { TrashIcon } from "~/components/icons/Trash"; import { userSubmittedImage } from "~/utils/urls"; import { action } from "../actions/upload.admin.server"; @@ -18,19 +22,63 @@ export default function ImageUploadAdminPage() { function ImageValidator() { const data = useLoaderData(); - if (!data.image) { + // biome-ignore lint/correctness/useExhaustiveDependencies: + React.useEffect(() => { + window.scrollTo(0, 0); + }, [data]); + + if (data.images.length === 0) { return <>All validated!; } return ( <> -
{data.unvalidatedImgCount} left
- -
- - Ok +
{data.unvalidatedImgCount} left
+
+ {data.images.map((image, i) => { + return ( +
+
+ {i + 1}){" "} + + } + variant="minimal-destructive" + size="medium" + /> + +
+ + + From: {image.username} + +
+ ); + })} +
+ + + img.id))} + /> + + All {data.images.length} above ok +
-
From: {data.image.submitterUserId}
); } diff --git a/app/features/img-upload/upload-constants.ts b/app/features/img-upload/upload-constants.ts index bb664d0e3..af01c9de9 100644 --- a/app/features/img-upload/upload-constants.ts +++ b/app/features/img-upload/upload-constants.ts @@ -2,6 +2,8 @@ import type { ImageUploadType } from "./upload-types"; export const MAX_UNVALIDATED_IMG_COUNT = 5; +export const IMAGES_TO_VALIDATE_AT_ONCE = 5; + export const IMAGE_TYPES = ["team-pfp", "org-pfp", "team-banner"] as const; export const imgTypeToDimensions: Record< diff --git a/app/features/img-upload/upload-schemas.server.ts b/app/features/img-upload/upload-schemas.server.ts index 361cf225f..83590e660 100644 --- a/app/features/img-upload/upload-schemas.server.ts +++ b/app/features/img-upload/upload-schemas.server.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -import { id } from "~/utils/zod"; +import { _action, id, safeJSONParse } from "~/utils/zod"; -export const validateImageSchema = z.object({ +const validateManySchema = z.object({ + _action: _action("VALIDATE"), + imageIds: z.preprocess(safeJSONParse, z.array(id).min(1).max(5)), +}); + +const rejectSchema = z.object({ + _action: _action("REJECT"), imageId: id, }); + +export const validateImageSchema = z.union([validateManySchema, rejectSchema]); diff --git a/app/styles/utils.css b/app/styles/utils.css index 2a17d3eb6..81b878aa3 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -246,6 +246,10 @@ margin-block-start: var(--s-6); } +.mt-12 { + margin-block-start: var(--s-12); +} + .mb-0 { margin-block-end: 0; }