mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-11 05:05:07 -05:00
* 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
203 lines
5.7 KiB
TypeScript
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]} />
|
|
);
|
|
}
|