Allow rejecting uploaded images + validate many at a time

This commit is contained in:
Kalle
2025-04-17 10:31:55 +03:00
parent 5741577bc5
commit 7a2b78252a
8 changed files with 118 additions and 21 deletions

View File

@@ -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();
});
}

View File

@@ -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;

View File

@@ -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(),
};
};

View File

@@ -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<UnvalidatedImage>;
}

View File

@@ -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<typeof loader>();
if (!data.image) {
// biome-ignore lint/correctness/useExhaustiveDependencies:
React.useEffect(() => {
window.scrollTo(0, 0);
}, [data]);
if (data.images.length === 0) {
return <>All validated!</>;
}
return (
<>
<div>{data.unvalidatedImgCount} left</div>
<img src={userSubmittedImage(data.image.url)} alt="" />
<Form method="post">
<input type="hidden" name="imageId" value={data.image.id} />
<SubmitButton>Ok</SubmitButton>
<div className="text-lighter">{data.unvalidatedImgCount} left</div>
<div className="stack md">
{data.images.map((image, i) => {
return (
<div key={image.id}>
<div className="text-lg font-bold stack horizontal md">
{i + 1}){" "}
<FormWithConfirm
dialogHeading={`Reject image submitted by ${image.username}?`}
deleteButtonText="Reject"
fields={[
["imageId", image.id],
["_action", "REJECT"],
]}
>
<SendouButton
icon={<TrashIcon />}
variant="minimal-destructive"
size="medium"
/>
</FormWithConfirm>
</div>
<img src={userSubmittedImage(image.url)} alt="" />
<Link
to={`/u/${image.submitterUserId}`}
className="text-xs"
target="_blank"
rel="noopener noreferrer"
>
From: {image.username}
</Link>
</div>
);
})}
</div>
<Form method="post" className="mt-12">
<input
type="hidden"
name="imageIds"
value={JSON.stringify(data.images.map((img) => img.id))}
/>
<SubmitButton size="big" className="mx-auto" _action="VALIDATE">
All {data.images.length} above ok
</SubmitButton>
</Form>
<div>From: {data.image.submitterUserId}</div>
</>
);
}

View File

@@ -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<

View File

@@ -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]);

View File

@@ -246,6 +246,10 @@
margin-block-start: var(--s-6);
}
.mt-12 {
margin-block-start: var(--s-12);
}
.mb-0 {
margin-block-end: 0;
}