sendou.ink/app/features/img-upload/routes/upload.tsx
Kalle ef78d3a2c2
Tournament full (#1373)
* Got something going

* Style overwrites

* width != height

* More playing with lines

* Migrations

* Start bracket initial

* Unhardcode stage generation params

* Link to match page

* Matches page initial

* Support directly adding seed to map list generator

* Add docs

* Maps in matches page

* Add invariant about tie breaker map pool

* Fix PICNIC lacking tie breaker maps

* Only link in bracket when tournament has started

* Styled tournament roster inputs

* Prefer IGN in tournament match page

* ModeProgressIndicator

* Some conditional rendering

* Match action initial + better error display

* Persist bestOf in DB

* Resolve best of ahead of time

* Move brackets-manager to core

* Score reporting works

* Clear winner on score report

* ModeProgressIndicator: highlight winners

* Fix inconsistent input

* Better text when submitting match

* mapCountPlayedInSetWithCertainty that works

* UNDO_REPORT_SCORE implemented

* Permission check when starting tournament

* Remove IGN from upsert

* View match results page

* Source in DB

* Match page waiting for teams

* Move tournament bracket to feature folder

* REOPEN_MATCH initial

* Handle proper resetting of match

* Inline bracket-manager

* Syncify

* Transactions

* Handle match is locked gracefully

* Match page auto refresh

* Fix match refresh called "globally"

* Bracket autoupdate

* Move fillWithNullTillPowerOfTwo to utils with testing

* Fix map lists not visible after tournament started

* Optimize match events

* Show UI while in progress to members

* Fix start tournament alert not being responsive

* Teams can check in

* Fix map list 400

* xxx -> TODO

* Seeds page

* Remove map icons for team page

* Don't display link to seeds after tournament has started

* Admin actions initial

* Change captain admin action

* Make all hooks ts

* Admin actions functioning

* Fix validate error not displaying in CatchBoundary

* Adjust validate args order

* Remove admin loader

* Make delete team button menancing

* Only include checked in teams to bracket

* Optimize to.id route loads

* Working show map list generator toggle

* Update full tournaments flow

* Make full tournaments work with many start times

* Handle undefined in crud

* Dynamic stage banner

* Handle default strat if map list generation fails

* Fix crash on brackets if less than 2 teams

* Add commented out test for reference

* Add TODO

* Add players from team during register

* TrustRelationship

* Prefers not to host feature

* Last before merge

* Rename some vars

* More renames
2023-05-15 22:37:43 +03:00

203 lines
5.7 KiB
TypeScript

import type { ActionArgs, LoaderArgs, UploadHandler } from "@remix-run/node";
import {
redirect,
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/node";
import { useFetcher, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { Main } from "~/components/Main";
import Compressor from "compressorjs";
import invariant from "tiny-invariant";
import { Button } from "~/components/Button";
import { useTranslation } from "~/hooks/useTranslation";
import { requireUser } from "~/modules/auth";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { validate } from "~/utils/remix";
import { teamPage } from "~/utils/urls";
import { addNewImage } from "../queries/addNewImage";
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
import { s3UploadHandler } from "../s3.server";
import {
imgTypeToDimensions,
imgTypeToStyle,
MAX_UNVALIDATED_IMG_COUNT,
} from "../upload-constants";
import type { ImageUploadType } from "../upload-types";
import { requestToImgType } from "../upload-utils";
import { findByIdentifier } from "~/features/team";
import { isTeamOwner } from "~/features/team";
export const action = async ({ request }: ActionArgs) => {
const user = await requireUser(request);
const validatedType = requestToImgType(request);
validate(validatedType, "Invalid image type");
validate(user.team, "You must be on a team to upload images");
const detailed = findByIdentifier(user.team.customUrl);
validate(
detailed && isTeamOwner({ team: detailed.team, user }),
"You must be the team owner to upload images"
);
// TODO: graceful error handling when uploading many images
validate(
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
"Too many unvalidated images"
);
const uploadHandler: UploadHandler = composeUploadHandlers(
s3UploadHandler,
createMemoryUploadHandler()
);
const formData = await parseMultipartFormData(request, uploadHandler);
const imgSrc = formData.get("img") as string | null;
invariant(imgSrc);
const urlParts = imgSrc.split("/");
const fileName = urlParts[urlParts.length - 1];
invariant(fileName);
const shouldAutoValidate = Boolean(user.patronTier);
addNewImage({
submitterUserId: user.id,
teamId: user.team.id,
type: validatedType,
url: fileName,
validatedAt: shouldAutoValidate
? dateToDatabaseTimestamp(new Date())
: null,
});
if (shouldAutoValidate) {
return redirect(teamPage(user.team.customUrl));
}
return null;
};
export const loader = async ({ request }: LoaderArgs) => {
const user = await requireUser(request);
const validatedType = requestToImgType(request);
if (!validatedType || !user.team) {
throw redirect("/");
}
const detailed = findByIdentifier(user.team.customUrl);
if (!detailed || !isTeamOwner({ team: detailed.team, user })) {
throw redirect("/");
}
return {
type: validatedType,
unvalidatedImages: countUnvalidatedImg(user.id),
};
};
export default function FileUploadPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const [img, setImg] = React.useState<File | null>(null);
const fetcher = useFetcher();
const handleSubmit = () => {
invariant(img);
const formData = new FormData();
formData.append("img", img, img.name);
fetcher.submit(formData, {
encType: "multipart/form-data",
method: "post",
});
};
React.useEffect(() => {
if (fetcher.state === "loading") {
setImg(null);
}
}, [fetcher.state]);
const { width, height } = imgTypeToDimensions[data.type];
return (
<Main className="stack lg">
<div>
<div>
{t("common:upload.title", {
type: t(`common:upload.type.${data.type}`),
width,
height,
})}
</div>
<div className="text-sm text-lighter">
{t("common:upload.commonExplanation")}{" "}
{data.unvalidatedImages ? (
<span>
{t("common:upload.afterExplanation", {
count: data.unvalidatedImages,
})}
</span>
) : null}
</div>
</div>
<div>
<label htmlFor="img-field">{t("common:upload.imageToUpload")}</label>
<input
id="img-field"
className="plain"
type="file"
name="img"
accept="image/png, image/jpeg, image/webp"
onChange={(e) => {
const uploadedFile = e.target.files?.[0];
if (!uploadedFile) {
setImg(null);
return;
}
new Compressor(uploadedFile, {
height,
width,
maxHeight: height,
maxWidth: width,
// 0.5MB
convertSize: 500_000,
resize: "cover",
success(result) {
const file = new File([result], `img.webp`, {
type: "image/webp",
});
setImg(file);
},
error(err) {
console.error(err.message);
},
});
}}
/>
</div>
{img ? <PreviewImage img={img} type={data.type} /> : null}
<Button
className="self-start"
disabled={!img || fetcher.state !== "idle"}
onClick={handleSubmit}
>
{t("common:actions.upload")}
</Button>
</Main>
);
}
function PreviewImage({ img, type }: { img: File; type: ImageUploadType }) {
return (
<img src={URL.createObjectURL(img)} alt="" style={imgTypeToStyle[type]} />
);
}