Logos for tournament pickups (#1768)

* Progress

* Adjust when elements show up etc.

* Reorganize

* Can upload

* Delete logo admin action

* Filter out avas pre-start

* Util func

* Remove old code

* Fixes
This commit is contained in:
Kalle 2024-06-22 14:35:04 +03:00 committed by GitHub
parent a7f77d9384
commit 2c5004f623
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 961 additions and 535 deletions

View File

@ -5,6 +5,8 @@ import { useTranslation } from "react-i18next";
import { Button, type ButtonProps } from "./Button";
import { Dialog } from "./Dialog";
import { SubmitButton } from "./SubmitButton";
import { useIsMounted } from "~/hooks/useIsMounted";
import { createPortal } from "react-dom";
export function FormWithConfirm({
fields,
@ -33,6 +35,7 @@ export function FormWithConfirm({
const componentsFetcher = useFetcher();
const fetcher = _fetcher ?? componentsFetcher;
const isMounted = useIsMounted();
const { t } = useTranslation(["common"]);
const [dialogOpen, setDialogOpen] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
@ -51,17 +54,23 @@ export function FormWithConfirm({
return (
<>
<fetcher.Form
id={id}
className="hidden"
ref={formRef}
method="post"
action={action}
>
{fields?.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
</fetcher.Form>
{isMounted
? // using portal here makes nesting this component in another form work
createPortal(
<fetcher.Form
id={id}
className="hidden"
ref={formRef}
method="post"
action={action}
>
{fields?.map(([name, value]) => (
<input type="hidden" key={name} name={name} value={value} />
))}
</fetcher.Form>,
document.body,
)
: null}
<Dialog isOpen={dialogOpen} close={closeDialog} className="text-center">
<div className="stack md">
<h2 className="text-sm">{dialogHeading}</h2>
@ -82,6 +91,7 @@ export function FormWithConfirm({
{React.cloneElement(children, {
// @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode
onClick: openDialog,
type: "button",
})}
</>
);

View File

@ -606,6 +606,7 @@ export interface TournamentTeam {
>;
tournamentId: number;
teamId: number | null;
avatarImgId: number | null;
}
export interface TournamentTeamCheckIn {

View File

@ -5,13 +5,14 @@ import { parseParams } from "~/utils/remix";
import { id } from "~/utils/zod";
import type { GetTournamentTeamsResponse } from "../schema";
import { databaseTimestampToDate } from "~/utils/dates";
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import i18next from "~/modules/i18n/i18next.server";
import { cors } from "remix-utils/cors";
import { userSubmittedImage } from "~/utils/urls";
const paramsSchema = z.object({
id,
@ -29,6 +30,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const teams = await db
.selectFrom("TournamentTeam")
.leftJoin("UserSubmittedImage", "avatarImgId", "UserSubmittedImage.id")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
@ -44,6 +46,22 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
"TournamentTeam.seed",
"TournamentTeam.createdAt",
"TournamentTeamCheckIn.checkedInAt",
"UserSubmittedImage.url as avatarUrl",
jsonObjectFrom(
eb
.selectFrom("AllTeam")
.leftJoin(
"UserSubmittedImage",
"AllTeam.avatarImgId",
"UserSubmittedImage.id",
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select([
"AllTeam.customUrl",
"UserSubmittedImage.url as logoUrl",
"AllTeam.deletedAt",
]),
).as("team"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeamMember")
@ -74,11 +92,22 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
.orderBy("TournamentTeam.createdAt asc")
.execute();
const logoUrl = (team: (typeof teams)[number]) => {
const url = team.team?.logoUrl ?? team.avatarUrl;
if (!url) return null;
return userSubmittedImage(url);
};
const result: GetTournamentTeamsResponse = teams.map((team) => {
return {
id: team.id,
name: team.name,
url: `https://sendou.ink/to/${id}/teams/${team.id}`,
teamPageUrl:
team.team?.customUrl && !team.team.deletedAt
? `https://sendou.ink/t/${team.team.customUrl}`
: null,
seed: team.seed,
registeredAt: databaseTimestampToDate(team.createdAt).toISOString(),
checkedIn: Boolean(team.checkedInAt),
@ -94,6 +123,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
joinedAt: databaseTimestampToDate(member.createdAt).toISOString(),
};
}),
logoUrl: logoUrl(team),
mapPool:
team.mapPool.length > 0
? team.mapPool.map((map) => {

View File

@ -82,9 +82,21 @@ export type GetTournamentTeamsResponse = Array<{
registeredAt: string;
checkedIn: boolean;
/**
* URL for the tournament team page.
*
* @example "https://sendou.ink/to/9/teams/327"
*/
url: string;
/**
* URL for the global team page.
*
* @example "https://sendou.ink/t/moonlight"
*/
teamPageUrl: string | null;
/**
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp"
*/
logoUrl: string | null;
seed: number | null;
mapPool: Array<StageWithMode> | null;
members: Array<{

View File

@ -1,10 +1,5 @@
import type { ActionFunction } from "@remix-run/node";
import {
redirect,
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { z } from "zod";
import type { CalendarEventTag } from "~/db/types";
import { requireUser } from "~/features/auth/core/user.server";
@ -24,7 +19,12 @@ import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { badRequestIfFalsy, parseFormData, validate } from "~/utils/remix";
import {
badRequestIfFalsy,
parseFormData,
uploadImageIfSubmitted,
validate,
} from "~/utils/remix";
import { calendarEventPage } from "~/utils/urls";
import {
actualNumber,
@ -38,21 +38,25 @@ import {
safeJSONParse,
toArray,
} from "~/utils/zod";
import { canAddNewEvent, regClosesAtDate } from "../calendar-utils";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
import {
calendarEventMaxDate,
calendarEventMinDate,
canAddNewEvent,
regClosesAtDate,
} from "../calendar-utils";
import {
canCreateTournament,
formValuesToBracketProgression,
} from "../calendar-utils.server";
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "../calendar-constants";
import { calendarEventMaxDate, calendarEventMinDate } from "../calendar-utils";
import { nanoid } from "nanoid";
import { s3UploadHandler } from "~/features/img-upload";
import invariant from "~/utils/invariant";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
const { avatarFileName, formData } = await uploadAvatarIfExists(request);
const { avatarFileName, formData } = await uploadImageIfSubmitted({
request,
fileNamePrefix: "tournament-logo",
});
const data = await parseFormData({
formData,
schema: newCalendarEventActionSchema,
@ -174,38 +178,6 @@ export const action: ActionFunction = async ({ request }) => {
}
};
async function uploadAvatarIfExists(request: Request) {
const uploadHandler = composeUploadHandlers(
s3UploadHandler(`tournament-logo-${nanoid()}-${Date.now()}`),
createMemoryUploadHandler(),
);
try {
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);
return {
avatarFileName: fileName,
formData,
};
} catch (err) {
// user did not submit image
if (err instanceof TypeError) {
return {
avatarFileName: undefined,
formData: await request.formData(),
};
}
throw err;
}
}
export const newCalendarEventActionSchema = z
.object({
eventToEditId: z.preprocess(actualNumber, id.nullish()),

View File

@ -4,7 +4,13 @@ export function findByUserId(userId: number) {
return db
.selectFrom("TeamMember")
.innerJoin("Team", "Team.id", "TeamMember.teamId")
.select(["Team.id", "Team.customUrl", "Team.name"])
.leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId")
.select([
"Team.id",
"Team.customUrl",
"Team.name",
"UserSubmittedImage.url as logoUrl",
])
.where("TeamMember.userId", "=", userId)
.executeTakeFirst();
}

View File

@ -40,7 +40,7 @@ export function MatchRosters({
return (
<div className="tournament-bracket__rosters">
<div>
<div className="stack xxs">
<div className="stack xs horizontal items-center text-lighter">
<div className="tournament-bracket__team-one-dot" />
Team 1
@ -48,6 +48,8 @@ export function MatchRosters({
<h2
className={clsx("text-sm", {
"text-lighter": !teamOne,
"tournament-bracket__rosters__spaced-header":
teamOneLogoSrc || teamTwoLogoSrc,
})}
>
{teamOne ? (
@ -59,7 +61,7 @@ export function MatchRosters({
className="text-main-forced font-bold stack horizontal xs items-center"
>
{teamOneLogoSrc ? (
<Avatar url={teamOneLogoSrc} size="xxs" />
<Avatar url={teamOneLogoSrc} size="sm" />
) : null}
{teamOne.name}
</Link>
@ -92,12 +94,18 @@ export function MatchRosters({
</ul>
) : null}
</div>
<div>
<div className="stack xxs">
<div className="stack xs horizontal items-center text-lighter">
<div className="tournament-bracket__team-two-dot" />
Team 2
</div>
<h2 className={clsx("text-sm", { "text-lighter": !teamTwo })}>
<h2
className={clsx("text-sm", {
"text-lighter": !teamTwo,
"tournament-bracket__rosters__spaced-header":
teamOneLogoSrc || teamTwoLogoSrc,
})}
>
{teamTwo ? (
<Link
to={tournamentTeamPage({
@ -107,7 +115,7 @@ export function MatchRosters({
className="text-main-forced font-bold stack horizontal xs items-center"
>
{teamTwoLogoSrc ? (
<Avatar url={teamTwoLogoSrc} size="xxs" />
<Avatar url={teamTwoLogoSrc} size="sm" />
) : null}
{teamTwo.name}
</Link>

View File

@ -4,6 +4,7 @@ import { notFoundIfFalsy } from "~/utils/remix";
import type { Unwrapped } from "~/utils/types";
import { getServerTournamentManager } from "./brackets-manager/manager.server";
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
import { isAdmin } from "~/permissions";
const manager = getServerTournamentManager();
@ -35,12 +36,14 @@ function dataMapped({
ctx: TournamentRepository.FindById;
user?: { id: number };
}) {
const revealAllMapPools =
data.stage.length > 0 ||
const tournamentHasStarted = data.stage.length > 0;
const isOrganizer =
ctx.author.id === user?.id ||
ctx.staff.some(
(staff) => staff.id === user?.id && staff.role === "ORGANIZER",
);
) ||
isAdmin(user);
const revealInfo = tournamentHasStarted || isOrganizer;
return {
data,
@ -53,7 +56,9 @@ function dataMapped({
return {
...team,
mapPool: revealAllMapPools || isOwnTeam ? team.mapPool : null,
mapPool: revealInfo || isOwnTeam ? team.mapPool : null,
pickupAvatarUrl:
revealInfo || isOwnTeam ? team.pickupAvatarUrl : null,
inviteCode: isOwnTeam ? team.inviteCode : null,
};
}),

View File

@ -488,9 +488,11 @@ export class Tournament {
}
tournamentTeamLogoSrc(team: TournamentDataTeam) {
if (!team.team?.logoUrl) return;
const url = team.team?.logoUrl ?? team.pickupAvatarUrl;
return userSubmittedImage(team.team.logoUrl);
if (!url) return;
return userSubmittedImage(url);
}
resolvePoolCode({

View File

@ -33,6 +33,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({
team: null,
seed: 1,
activeRosterUserIds: [],
pickupAvatarUrl: null,
});
function summarize({ results }: { results?: AllMatchResult[] } = {}) {

View File

@ -2097,6 +2097,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709743534,
members: [
{
@ -2207,6 +2208,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709737918,
members: [
{
@ -2330,6 +2332,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709743523,
members: [
{
@ -2440,6 +2443,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709743262,
members: [
{
@ -2550,6 +2554,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709741396,
members: [
{
@ -2673,6 +2678,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709711811,
members: [
{
@ -2796,6 +2802,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709738831,
members: [
{
@ -2919,6 +2926,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709737837,
members: [
{
@ -3029,6 +3037,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709741719,
members: [
{
@ -3165,6 +3174,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709730354,
members: [
{
@ -3288,6 +3298,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709745630,
members: [
{
@ -3408,6 +3419,7 @@ export const PADDLING_POOL_257 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709592381,
members: [
{
@ -3544,6 +3556,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709723749,
members: [
{
@ -3667,6 +3680,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709668399,
members: [
{
@ -3803,6 +3817,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709735267,
members: [
{
@ -3917,6 +3932,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709745849,
members: [
{
@ -4031,6 +4047,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709742258,
members: [
{
@ -4151,6 +4168,7 @@ export const PADDLING_POOL_257 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709738744,
members: [
{
@ -4274,6 +4292,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709746054,
members: [
{
@ -4385,6 +4404,7 @@ export const PADDLING_POOL_257 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709744894,
members: [
{
@ -4495,6 +4515,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709728278,
members: [
{
@ -4605,6 +4626,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709715006,
members: [
{
@ -4715,6 +4737,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709660578,
members: [
{
@ -4829,6 +4852,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709721869,
members: [
{
@ -4953,6 +4977,7 @@ export const PADDLING_POOL_257 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709743633,
members: [
{
@ -5067,6 +5092,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709738747,
members: [
{
@ -5194,6 +5220,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709626047,
members: [
{
@ -5317,6 +5344,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709727951,
members: [
{
@ -5427,6 +5455,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709741482,
members: [
{
@ -5560,6 +5589,7 @@ export const PADDLING_POOL_257 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709744451,
members: [
{
@ -5674,6 +5704,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709726536,
members: [
{
@ -5784,6 +5815,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709558706,
members: [
{
@ -5898,6 +5930,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709744323,
members: [
{
@ -6025,6 +6058,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709677397,
members: [
{
@ -6135,6 +6169,7 @@ export const PADDLING_POOL_257 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1709618711,
members: [
{
@ -7962,6 +7997,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708476597,
members: [
{
@ -8072,6 +8108,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708535137,
members: [
{
@ -8182,6 +8219,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708533764,
members: [
{
@ -8302,6 +8340,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708537512,
members: [
{
@ -8425,6 +8464,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708533309,
members: [
{
@ -8535,6 +8575,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708430641,
members: [
{
@ -8658,6 +8699,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708536306,
members: [
{
@ -8765,6 +8807,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708526368,
members: [
{
@ -8875,6 +8918,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708506060,
members: [
{
@ -9011,6 +9055,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708526814,
members: [
{
@ -9118,6 +9163,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708466421,
members: [
{
@ -9241,6 +9287,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708377426,
members: [
{
@ -9361,6 +9408,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708448289,
members: [
{
@ -9497,6 +9545,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708532602,
members: [
{
@ -9607,6 +9656,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708535205,
members: [
{
@ -9730,6 +9780,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708515945,
members: [
{
@ -9840,6 +9891,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708453334,
members: [
{
@ -9947,6 +9999,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708522730,
members: [
{
@ -10070,6 +10123,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708375443,
members: [
{
@ -10193,6 +10247,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708532665,
members: [
{
@ -10307,6 +10362,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708364254,
members: [
{
@ -10434,6 +10490,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708464101,
members: [
{
@ -10558,6 +10615,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708520249,
members: [
{
@ -10681,6 +10739,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708535804,
members: [
{
@ -10791,6 +10850,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708535891,
members: [
{
@ -10914,6 +10974,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708521749,
members: [
{
@ -11037,6 +11098,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708536584,
members: [
{
@ -11147,6 +11209,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708537772,
members: [
{
@ -11287,6 +11350,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708379916,
members: [
{
@ -11410,6 +11474,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708519753,
members: [
{
@ -11537,6 +11602,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708534312,
members: [
{
@ -11661,6 +11727,7 @@ export const PADDLING_POOL_255 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708531929,
members: [
{
@ -11771,6 +11838,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708477155,
members: [
{
@ -11885,6 +11953,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708531564,
members: [
{
@ -12038,6 +12107,7 @@ export const PADDLING_POOL_255 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1708503356,
members: [
{
@ -14142,6 +14212,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707443313,
members: [
{
@ -14240,6 +14311,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707366405,
members: [
{
@ -14338,6 +14410,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1706912643,
members: [
{
@ -14436,6 +14509,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707359335,
members: [
{
@ -14560,6 +14634,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707171426,
members: [
{
@ -14671,6 +14746,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707342696,
members: [
{
@ -14782,6 +14858,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707513942,
members: [
{
@ -14906,6 +14983,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707526815,
members: [
{
@ -15030,6 +15108,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707583385,
members: [
{
@ -15128,6 +15207,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707486395,
members: [
{
@ -15226,6 +15306,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707513290,
members: [
{
@ -15324,6 +15405,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707531084,
members: [
{
@ -15422,6 +15504,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707568466,
members: [
{
@ -15533,6 +15616,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707481625,
members: [
{
@ -15631,6 +15715,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707530166,
members: [
{
@ -15729,6 +15814,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707181792,
members: [
{
@ -15840,6 +15926,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707550321,
members: [
{
@ -15955,6 +16042,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707575096,
members: [
{
@ -16066,6 +16154,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707569490,
members: [
{
@ -16190,6 +16279,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707537425,
members: [
{
@ -16288,6 +16378,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707564691,
members: [
{
@ -16416,6 +16507,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707145818,
members: [
{
@ -16522,6 +16614,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707558330,
members: [
{
@ -16620,6 +16713,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707586842,
members: [
{
@ -16718,6 +16812,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707583597,
members: [
{
@ -16842,6 +16937,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707429804,
members: [
{
@ -16953,6 +17049,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707539973,
members: [
{
@ -17068,6 +17165,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707507831,
members: [
{
@ -17179,6 +17277,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707586297,
members: [
{
@ -17287,6 +17386,7 @@ export const IN_THE_ZONE_32 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707583885,
members: [
{
@ -17411,6 +17511,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707578076,
members: [
{
@ -17523,6 +17624,7 @@ export const IN_THE_ZONE_32 = () =>
prefersNotToHost: 1,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707582953,
members: [
{
@ -17621,6 +17723,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707575330,
members: [
{
@ -17732,6 +17835,7 @@ export const IN_THE_ZONE_32 = () =>
team: null,
inviteCode: null,
activeRosterUserIds: [],
pickupAvatarUrl: null,
createdAt: 1707527645,
members: [
{

View File

@ -17,6 +17,7 @@ const tournamentCtxTeam = (
mapPool: [],
members: [],
activeRosterUserIds: [],
pickupAvatarUrl: null,
name: "Team " + teamId,
prefersNotToHost: 0,
droppedOut: 0,

View File

@ -311,6 +311,12 @@
list-style: none;
}
.tournament-bracket__rosters__spaced-header {
min-height: 45px;
display: flex;
align-items: center;
}
@media screen and (min-width: 640px) {
.tournament-bracket__rosters {
justify-content: space-evenly;

View File

@ -78,6 +78,11 @@ export async function findById(id: number) {
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
.leftJoin(
"UserSubmittedImage",
"TournamentTeam.avatarImgId",
"UserSubmittedImage.id",
)
.select(({ eb: innerEb }) => [
"TournamentTeam.id",
"TournamentTeam.name",
@ -88,6 +93,7 @@ export async function findById(id: number) {
"TournamentTeam.inviteCode",
"TournamentTeam.createdAt",
"TournamentTeam.activeRosterUserIds",
"UserSubmittedImage.url as pickupAvatarUrl",
jsonArrayFrom(
innerEb
.selectFrom("TournamentTeamMember")

View File

@ -1,7 +1,11 @@
// TODO: add rest of the functions here that relate more to tournament teams than tournament/bracket
import type { Transaction } from "kysely";
import { sql } from "kysely";
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
export function setActiveRoster({
@ -85,3 +89,123 @@ export async function updateMemberInGameNameForNonStarted({
.execute()
);
}
export function create({
team,
avatarFileName,
userId,
tournamentId,
ownerInGameName,
}: {
team: Pick<
Tables["TournamentTeam"],
"name" | "prefersNotToHost" | "noScreen" | "teamId"
>;
avatarFileName?: string;
userId: number;
tournamentId: number;
ownerInGameName: string | null;
}) {
return db.transaction().execute(async (trx) => {
const avatarImgId = avatarFileName
? await createSubmittedImageInTrx({
trx,
avatarFileName,
userId,
})
: null;
const tournamentTeam = await trx
.insertInto("TournamentTeam")
.values({
tournamentId,
name: team.name,
inviteCode: nanoid(INVITE_CODE_LENGTH),
prefersNotToHost: team.prefersNotToHost,
noScreen: team.noScreen,
teamId: team.teamId,
avatarImgId,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("TournamentTeamMember")
.values({
tournamentTeamId: tournamentTeam.id,
userId,
isOwner: 1,
inGameName: ownerInGameName,
})
.execute();
});
}
export function update({
team,
avatarFileName,
userId,
}: {
team: Pick<
Tables["TournamentTeam"],
"id" | "name" | "prefersNotToHost" | "noScreen" | "teamId"
>;
avatarFileName?: string;
userId: number;
}) {
return db.transaction().execute(async (trx) => {
const avatarImgId = avatarFileName
? await createSubmittedImageInTrx({
trx,
avatarFileName,
userId,
})
: // don't overwrite the existing avatarImgId even if no new avatar is provided
// delete is a separate functionality
undefined;
await trx
.updateTable("TournamentTeam")
.set({
name: team.name,
prefersNotToHost: team.prefersNotToHost,
noScreen: team.noScreen,
teamId: team.teamId,
avatarImgId,
})
.where("TournamentTeam.id", "=", team.id)
.execute();
});
}
async function createSubmittedImageInTrx({
trx,
avatarFileName,
userId,
}: {
trx: Transaction<DB>;
avatarFileName: string;
userId: number;
}) {
const result = await trx
.insertInto("UnvalidatedUserSubmittedImage")
.values({
url: avatarFileName,
// in the context of tournament teams images are treated as globally "validated"
// instead the TO takes responsibility for removing inappropriate images
validatedAt: databaseTimestampNow(),
submitterUserId: userId,
})
.returning("id")
.executeTakeFirstOrThrow();
return result.id;
}
export function deleteLogo(tournamentTeamId: number) {
return db
.updateTable("TournamentTeam")
.set({ avatarImgId: null })
.where("TournamentTeam.id", "=", tournamentTeamId)
.execute();
}

View File

@ -0,0 +1,246 @@
import { type ActionFunction } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import * as QRepository from "~/features/sendouq/QRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
notFoundIfFalsy,
parseFormData,
uploadImageIfSubmitted,
validate,
} from "~/utils/remix";
import { booleanToInt } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { checkIn } from "../queries/checkIn.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
import { TOURNAMENT } from "../tournament-constants";
import { registerSchema } from "../tournament-schemas.server";
import {
isOneModeTournamentOf,
tournamentIdFromParams,
validateCounterPickMapPool,
} from "../tournament-utils";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { avatarFileName, formData } = await uploadImageIfSubmitted({
request,
fileNamePrefix: "pickup-logo",
});
const data = await parseFormData({
formData,
schema: registerSchema,
});
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
validate(
!tournament.hasStarted,
"Tournament has started, cannot make edits to registration",
);
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
switch (data._action) {
case "UPSERT_TEAM": {
validate(
!data.teamId ||
(await TeamRepository.findByUserId(user.id))?.id === data.teamId,
"Team id does not match the team you are in",
);
if (ownTeam) {
validate(
tournament.registrationOpen || data.teamName === ownTeam.name,
"Can't change team name after registration has closed",
);
await TournamentTeamRepository.update({
userId: user.id,
avatarFileName,
team: {
id: ownTeam.id,
name: data.teamName,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
noScreen: booleanToInt(data.noScreen),
teamId: data.teamId ?? null,
},
});
} else {
validate(!tournament.isInvitational, "Event is invite only");
validate(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(
!tournament.teamMemberOfByUser(user),
"You are already in a team that you aren't captain of",
);
validate(tournament.registrationOpen, "Registration is closed");
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: user.id,
}),
team: {
name: data.teamName,
noScreen: booleanToInt(data.noScreen),
prefersNotToHost: booleanToInt(data.prefersNotToHost),
teamId: data.teamId ?? null,
},
userId: user.id,
tournamentId,
avatarFileName,
});
}
break;
}
case "DELETE_TEAM_MEMBER": {
validate(ownTeam);
validate(ownTeam.members.some((member) => member.userId === data.userId));
validate(data.userId !== user.id);
const detailedOwnTeam = findOwnTournamentTeam({
tournamentId,
userId: user.id,
});
// making sure they aren't unfilling one checking in condition i.e. having full roster
// and then having members kicked without it affecting the checking in status
validate(
detailedOwnTeam &&
(!detailedOwnTeam.checkedInAt ||
ownTeam.members.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL),
);
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
break;
}
case "LEAVE_TEAM": {
validate(!ownTeam, "Can't leave a team as the owner");
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
teamMemberOf.checkIns.length === 0,
"You cannot leave after checking in",
);
deleteTeamMember({
tournamentTeamId: teamMemberOf.id,
userId: user.id,
});
break;
}
case "UPDATE_MAP_POOL": {
const mapPool = new MapPool(data.mapPool);
validate(ownTeam);
validate(
validateCounterPickMapPool(
mapPool,
isOneModeTournamentOf(event),
tournament.ctx.tieBreakerMapPool,
) === "VALID",
);
upsertCounterpickMaps({
tournamentTeamId: ownTeam.id,
mapPool: new MapPool(data.mapPool),
});
break;
}
case "CHECK_IN": {
logger.info(
`Checking in (try): owned tournament team id: ${ownTeam?.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
teamMemberOf.checkIns.length === 0,
"You have already checked in",
);
validate(tournament.regularCheckInIsOpen, "Check in is not open");
validate(
tournament.checkInConditionsFulfilledByTeamId(teamMemberOf.id),
"Check in conditions not fulfilled",
);
checkIn(teamMemberOf.id);
logger.info(
`Checking in (success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
break;
}
case "ADD_PLAYER": {
validate(
tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== data.userId),
),
"User is already in a team",
);
validate(ownTeam);
validate(
(await QRepository.usersThatTrusted(user.id)).some(
(trusterPlayer) => trusterPlayer.id === data.userId,
),
"No trust given from this user",
);
validate(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(tournament.registrationOpen, "Registration is closed");
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
break;
}
case "UNREGISTER": {
validate(ownTeam, "You are not registered to this tournament");
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
deleteTeam(ownTeam.id);
break;
}
case "DELETE_LOGO": {
validate(ownTeam, "You are not registered to this tournament");
await TournamentTeamRepository.deleteLogo(ownTeam.id);
break;
}
default: {
assertUnreachable(data);
}
}
clearTournamentDataCache(tournamentId);
return null;
};

View File

@ -0,0 +1,31 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
import * as QRepository from "~/features/sendouq/QRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findMapPoolByTeamId.server";
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
import { tournamentIdFromParams } from "../tournament-utils";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await getUser(request);
if (!user) return null;
const ownTournamentTeam = findOwnTournamentTeam({
tournamentId: tournamentIdFromParams(params),
userId: user.id,
});
if (!ownTournamentTeam)
return {
mapPool: null,
trusterPlayers: null,
team: await TeamRepository.findByUserId(user.id),
};
return {
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),
trusterPlayers: await QRepository.usersThatTrusted(user.id),
team: await TeamRepository.findByUserId(user.id),
};
};
export type TournamentRegisterPageLoader = typeof loader;

View File

@ -1,71 +0,0 @@
import { sql } from "~/db/sql";
import type { TournamentTeam, User } from "~/db/types";
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
const createTeamStm = sql.prepare(/*sql*/ `
insert into "TournamentTeam" (
"tournamentId",
"inviteCode",
"name",
"prefersNotToHost",
"noScreen",
"teamId"
) values (
@tournamentId,
@inviteCode,
@name,
@prefersNotToHost,
@noScreen,
@teamId
) returning *
`);
const createMemberStm = sql.prepare(/*sql*/ `
insert into "TournamentTeamMember" (
"tournamentTeamId",
"userId",
"inGameName",
"isOwner"
) values (
@tournamentTeamId,
@userId,
@inGameName,
1
)
`);
export const createTeam = sql.transaction(
({
tournamentId,
name,
ownerId,
ownerInGameName,
prefersNotToHost,
noScreen,
teamId,
}: {
tournamentId: TournamentTeam["tournamentId"];
name: TournamentTeam["name"];
ownerId: User["id"];
ownerInGameName: string | null;
prefersNotToHost: TournamentTeam["prefersNotToHost"];
noScreen: number;
teamId: number | null;
}) => {
const team = createTeamStm.get({
tournamentId,
name,
inviteCode: nanoid(INVITE_CODE_LENGTH),
prefersNotToHost,
noScreen,
teamId,
}) as TournamentTeam;
createMemberStm.run({
tournamentTeamId: team.id,
inGameName: ownerInGameName,
userId: ownerId,
});
},
);

View File

@ -1,36 +0,0 @@
import { sql } from "~/db/sql";
import type { TournamentTeam } from "~/db/types";
const stm = sql.prepare(/*sql*/ `
update
"TournamentTeam"
set
"name" = @name,
"prefersNotToHost" = @prefersNotToHost,
"noScreen" = @noScreen,
"teamId" = @teamId
where
"id" = @id
`);
export function updateTeamInfo({
id,
name,
prefersNotToHost,
noScreen,
teamId,
}: {
id: TournamentTeam["id"];
name: TournamentTeam["name"];
prefersNotToHost: TournamentTeam["prefersNotToHost"];
noScreen: number;
teamId: number | null;
}) {
stm.run({
id,
name,
prefersNotToHost,
noScreen,
teamId,
});
}

View File

@ -39,7 +39,6 @@ import {
} from "~/utils/urls";
import * as TournamentRepository from "../TournamentRepository.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import { createTeam } from "../queries/createTeam.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { adminActionSchema } from "../tournament-schemas.server";
@ -74,17 +73,19 @@ export const action: ActionFunction = async ({ request, params }) => {
"User already on a team",
);
createTeam({
name: data.teamName,
teamId: null,
tournamentId: tournamentId,
ownerId: data.userId,
prefersNotToHost: 0,
noScreen: 0,
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
userId: user.id,
}),
team: {
name: data.teamName,
noScreen: 0,
prefersNotToHost: 0,
teamId: null,
},
userId: user.id,
tournamentId,
});
break;
@ -316,6 +317,13 @@ export const action: ActionFunction = async ({ request, params }) => {
});
break;
}
case "DELETE_LOGO": {
validateIsTournamentOrganizer();
await TournamentTeamRepository.deleteLogo(data.teamId);
break;
}
default: {
assertUnreachable(data);
}
@ -448,6 +456,11 @@ const actions = [
inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as Input[],
when: ["IN_GAME_NAME_REQUIRED"],
},
{
type: "DELETE_LOGO",
inputs: ["REGISTERED_TEAM"] as Input[],
when: [],
},
] as const;
function TeamActions() {

View File

@ -1,10 +1,10 @@
import { type ActionFunction, type LoaderFunctionArgs } from "@remix-run/node";
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
import { Form, Link, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import Compressor from "compressorjs";
import Markdown from "markdown-to-jsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import invariant from "~/utils/invariant";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Button, LinkButton } from "~/components/Button";
@ -15,36 +15,27 @@ import { Image, ModeImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { NewTabs } from "~/components/NewTabs";
import { Popover } from "~/components/Popover";
import { Section } from "~/components/Section";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClockIcon } from "~/components/icons/Clock";
import { CrossIcon } from "~/components/icons/Cross";
import { DiscordIcon } from "~/components/icons/Discord";
import { UserIcon } from "~/components/icons/User";
import { useUser } from "~/features/auth/core/user";
import { getUser, requireUser } from "~/features/auth/core/user.server";
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { ModeMapPoolPicker } from "~/features/sendouq-settings/components/ModeMapPoolPicker";
import * as QRepository from "~/features/sendouq/QRepository.server";
import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import {
clearTournamentDataCache,
tournamentFromDB,
type TournamentDataTeam,
} from "~/features/tournament-bracket/core/Tournament.server";
import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findMapPoolByTeamId.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { type TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { filterOutFalsy } from "~/utils/arrays";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { booleanToInt } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import invariant from "~/utils/invariant";
import {
LOG_IN_URL,
SENDOU_INK_BASE_URL,
@ -54,244 +45,21 @@ import {
tournamentSubsPage,
userEditProfilePage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { checkIn } from "../queries/checkIn.server";
import { createTeam } from "../queries/createTeam.server";
import { deleteTeam } from "../queries/deleteTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTournamentTeam } from "../queries/findOwnTournamentTeam.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { updateTeamInfo } from "../queries/updateTeamInfo.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
import { TOURNAMENT } from "../tournament-constants";
import { registerSchema } from "../tournament-schemas.server";
import {
isOneModeTournamentOf,
tournamentIdFromParams,
type CounterPickValidationStatus,
validateCounterPickMapPool,
} from "../tournament-utils";
import { useTournament } from "./to.$id";
import Markdown from "markdown-to-jsx";
import { NewTabs } from "~/components/NewTabs";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { Toggle } from "~/components/Toggle";
import { DiscordIcon } from "~/components/icons/Discord";
import { inGameNameIfNeeded } from "../tournament-utils.server";
import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server";
import { TrashIcon } from "~/components/icons/Trash";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const data = await parseRequestFormData({ request, schema: registerSchema });
import { loader } from "../loaders/to.$id.register.server";
import { action } from "../actions/to.$id.register.server";
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
validate(
!tournament.hasStarted,
"Tournament has started, cannot make edits to registration",
);
const ownTeam = tournament.ownedTeamByUser(user);
const ownTeamCheckedIn = Boolean(ownTeam && ownTeam.checkIns.length > 0);
switch (data._action) {
case "UPSERT_TEAM": {
validate(
!data.teamId ||
(await TeamRepository.findByUserId(user.id))?.id === data.teamId,
"Team id does not match the team you are in",
);
if (ownTeam) {
validate(
tournament.registrationOpen || data.teamName === ownTeam.name,
"Can't change team name after registration has closed",
);
updateTeamInfo({
name: data.teamName,
id: ownTeam.id,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
noScreen: booleanToInt(data.noScreen),
teamId: data.teamId ?? null,
});
} else {
validate(!tournament.isInvitational, "Event is invite only");
validate(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(
!tournament.teamMemberOfByUser(user),
"You are already in a team that you aren't captain of",
);
validate(tournament.registrationOpen, "Registration is closed");
createTeam({
name: data.teamName,
tournamentId: tournamentId,
ownerId: user.id,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
noScreen: booleanToInt(data.noScreen),
ownerInGameName: await inGameNameIfNeeded({
tournament,
userId: user.id,
}),
teamId: data.teamId ?? null,
});
}
break;
}
case "DELETE_TEAM_MEMBER": {
validate(ownTeam);
validate(ownTeam.members.some((member) => member.userId === data.userId));
validate(data.userId !== user.id);
const detailedOwnTeam = findOwnTournamentTeam({
tournamentId,
userId: user.id,
});
// making sure they aren't unfilling one checking in condition i.e. having full roster
// and then having members kicked without it affecting the checking in status
validate(
detailedOwnTeam &&
(!detailedOwnTeam.checkedInAt ||
ownTeam.members.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL),
);
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
break;
}
case "LEAVE_TEAM": {
validate(!ownTeam, "Can't leave a team as the owner");
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
teamMemberOf.checkIns.length === 0,
"You cannot leave after checking in",
);
deleteTeamMember({
tournamentTeamId: teamMemberOf.id,
userId: user.id,
});
break;
}
case "UPDATE_MAP_POOL": {
const mapPool = new MapPool(data.mapPool);
validate(ownTeam);
validate(
validateCounterPickMapPool(
mapPool,
isOneModeTournamentOf(event),
tournament.ctx.tieBreakerMapPool,
) === "VALID",
);
upsertCounterpickMaps({
tournamentTeamId: ownTeam.id,
mapPool: new MapPool(data.mapPool),
});
break;
}
case "CHECK_IN": {
logger.info(
`Checking in (try): owned tournament team id: ${ownTeam?.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
teamMemberOf.checkIns.length === 0,
"You have already checked in",
);
validate(tournament.regularCheckInIsOpen, "Check in is not open");
validate(
tournament.checkInConditionsFulfilledByTeamId(teamMemberOf.id),
"Check in conditions not fulfilled",
);
checkIn(teamMemberOf.id);
logger.info(
`Checking in (success): tournament team id: ${teamMemberOf.id} - user id: ${user.id} - tournament id: ${tournamentId}`,
);
break;
}
case "ADD_PLAYER": {
validate(
tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== data.userId),
),
"User is already in a team",
);
validate(ownTeam);
validate(
(await QRepository.usersThatTrusted(user.id)).some(
(trusterPlayer) => trusterPlayer.id === data.userId,
),
"No trust given from this user",
);
validate(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(tournament.registrationOpen, "Registration is closed");
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,
tournamentId,
inGameName: await inGameNameIfNeeded({
tournament,
userId: data.userId,
}),
});
break;
}
case "UNREGISTER": {
validate(ownTeam, "You are not registered to this tournament");
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
deleteTeam(ownTeam.id);
break;
}
default: {
assertUnreachable(data);
}
}
clearTournamentDataCache(tournamentId);
return null;
};
export type TournamentRegisterPageLoader = typeof loader;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await getUser(request);
if (!user) return null;
const ownTournamentTeam = findOwnTournamentTeam({
tournamentId: tournamentIdFromParams(params),
userId: user.id,
});
if (!ownTournamentTeam)
return {
mapPool: null,
trusterPlayers: null,
team: await TeamRepository.findByUserId(user.id),
};
return {
mapPool: findMapPoolByTeamId(ownTournamentTeam.id),
trusterPlayers: await QRepository.usersThatTrusted(user.id),
team: await TeamRepository.findByUserId(user.id),
};
};
export { loader, action };
export default function TournamentRegisterPage() {
const user = useUser();
@ -527,7 +295,7 @@ function PleaseLogIn() {
}
function RegistrationForms() {
const data = useLoaderData<typeof loader>();
const data = useLoaderData<TournamentRegisterPageLoader>();
const user = useUser();
const tournament = useTournament();
@ -571,9 +339,7 @@ function RegistrationForms() {
<FriendCode />
{user?.friendCode ? (
<TeamInfo
name={ownTeam?.name}
prefersNotToHost={ownTeam?.prefersNotToHost}
noScreen={ownTeam?.noScreen}
ownTeam={ownTeam}
canUnregister={Boolean(ownTeam && !ownTeamCheckedIn)}
/>
) : null}
@ -784,25 +550,23 @@ function CheckIn({
}
function TeamInfo({
name,
prefersNotToHost = 0,
noScreen = 0,
ownTeam,
canUnregister,
}: {
name?: string;
prefersNotToHost?: number;
noScreen?: number;
ownTeam?: TournamentDataTeam | null;
canUnregister: boolean;
}) {
const data = useLoaderData<typeof loader>();
const data = useLoaderData<TournamentRegisterPageLoader>();
const { t } = useTranslation(["tournament", "common"]);
const fetcher = useFetcher();
const tournament = useTournament();
const [teamName, setTeamName] = React.useState(name);
const [teamName, setTeamName] = React.useState(ownTeam?.name ?? "");
const user = useUser();
const ref = React.useRef<HTMLFormElement>(null);
const [signUpWithTeam, setSignUpWithTeam] = React.useState(() =>
Boolean(tournament.ownedTeamByUser(user)?.team),
);
const [uploadedAvatar, setUploadedAvatar] = React.useState<File | null>(null);
const handleSignUpWithTeamChange = (checked: boolean) => {
if (!checked) {
@ -813,6 +577,50 @@ function TeamInfo({
}
};
const handleSubmit = () => {
const formData = new FormData(ref.current!);
if (uploadedAvatar) {
// replace with the compressed version
formData.delete("img");
formData.append("img", uploadedAvatar, uploadedAvatar.name);
}
fetcher.submit(formData, {
encType: uploadedAvatar ? "multipart/form-data" : undefined,
method: "post",
});
};
const submitButtonDisabled = () => {
if (fetcher.state !== "idle") return true;
return false;
};
const avatarUrl = (() => {
if (signUpWithTeam) {
if (ownTeam?.team?.logoUrl) {
return userSubmittedImage(ownTeam.team.logoUrl);
}
return data?.team?.logoUrl ? userSubmittedImage(data.team.logoUrl) : null;
}
if (uploadedAvatar) return URL.createObjectURL(uploadedAvatar);
if (ownTeam?.pickupAvatarUrl) {
return userSubmittedImage(ownTeam.pickupAvatarUrl);
}
return null;
})();
const canEditAvatar =
tournament.registrationOpen &&
!signUpWithTeam &&
uploadedAvatar &&
!ownTeam?.pickupAvatarUrl;
const canDeleteAvatar = ownTeam?.pickupAvatarUrl;
return (
<div>
<div className="stack horizontal justify-between">
@ -829,7 +637,6 @@ function TeamInfo({
className="build__small-text"
variant="minimal-destructive"
size="tiny"
type="submit"
>
{t("tournament:pre.info.unregister")}
</Button>
@ -837,7 +644,8 @@ function TeamInfo({
) : null}
</div>
<section className="tournament__section">
<fetcher.Form method="post" className="stack md items-center">
<Form method="post" className="stack md items-center" ref={ref}>
<input type="hidden" name="_action" value="UPSERT_TEAM" />
{signUpWithTeam && data?.team ? (
<input type="hidden" name="teamId" value={data.team.id} />
) : null}
@ -867,13 +675,48 @@ function TeamInfo({
readOnly={!tournament.registrationOpen || signUpWithTeam}
/>
</div>
{tournament.registrationOpen || avatarUrl ? (
<div className="tournament__section__input-container">
<Label htmlFor="logo">Logo</Label>
{avatarUrl ? (
<div className="stack horizontal md items-center">
<Avatar size="xsm" url={avatarUrl} />
{canEditAvatar ? (
<Button
variant="minimal"
size="tiny"
onClick={() => setUploadedAvatar(null)}
>
{t("common:actions.edit")}
</Button>
) : null}
{canDeleteAvatar ? (
<FormWithConfirm
dialogHeading="Delete team logo?"
fields={[["_action", "DELETE_LOGO"]]}
>
<Button
variant="minimal-destructive"
size="tiny"
type="submit"
>
<TrashIcon className="small-icon" />
</Button>
</FormWithConfirm>
) : null}
</div>
) : (
<TournamentLogoUpload onChange={setUploadedAvatar} />
)}
</div>
) : null}
<div className="stack sm">
<div className="text-lighter text-sm stack horizontal sm items-center">
<input
id="no-host"
type="checkbox"
name="prefersNotToHost"
defaultChecked={Boolean(prefersNotToHost)}
defaultChecked={Boolean(ownTeam?.prefersNotToHost)}
/>
<label htmlFor="no-host" className="mb-0">
{t("tournament:pre.info.noHost")}
@ -886,7 +729,7 @@ function TeamInfo({
id="no-screen"
type="checkbox"
name="noScreen"
defaultChecked={Boolean(noScreen)}
defaultChecked={Boolean(ownTeam?.noScreen)}
data-testid="no-screen-checkbox"
/>
<label htmlFor="no-screen" className="mb-0">
@ -896,19 +739,62 @@ function TeamInfo({
) : null}
</div>
</div>
<SubmitButton
_action="UPSERT_TEAM"
state={fetcher.state}
<Button
testId="save-team-button"
disabled={submitButtonDisabled()}
onClick={handleSubmit}
>
{t("common:actions.save")}
</SubmitButton>
</fetcher.Form>
</Button>
</Form>
</section>
</div>
);
}
const logoDimensions = imgTypeToDimensions["team-pfp"];
function TournamentLogoUpload({
onChange,
}: {
onChange: (file: File | null) => void;
}) {
return (
<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) {
onChange(null);
return;
}
new Compressor(uploadedFile, {
height: logoDimensions.height,
width: logoDimensions.width,
maxHeight: logoDimensions.height,
maxWidth: logoDimensions.width,
// 0.5MB
convertSize: 500_000,
resize: "cover",
success(result) {
const file = new File([result], `img.webp`, {
type: "image/webp",
});
onChange(file);
},
error(err) {
console.error(err.message);
},
});
}}
/>
);
}
function FriendCode() {
const user = useUser();
@ -936,7 +822,7 @@ function FillRoster({
ownTeam: TournamentDataTeam;
ownTeamCheckedIn: boolean;
}) {
const data = useLoaderData<typeof loader>();
const data = useLoaderData<TournamentRegisterPageLoader>();
const user = useUser();
const tournament = useTournament();
const [, copyToClipboard] = useCopyToClipboard();
@ -1159,7 +1045,7 @@ function CounterPickMapPoolPicker() {
const { t } = useTranslation(["common", "game-misc", "tournament"]);
const tournament = useTournament();
const fetcher = useFetcher();
const data = useLoaderData<typeof loader>();
const data = useLoaderData<TournamentRegisterPageLoader>();
const [counterPickMaps, setCounterPickMaps] = React.useState(
data?.mapPool ?? [],
);
@ -1269,81 +1155,6 @@ function MapPoolValidationStatusMessage({
);
}
type CounterPickValidationStatus =
| "PICKING"
| "VALID"
| "TOO_MUCH_STAGE_REPEAT"
| "STAGE_REPEAT_IN_SAME_MODE"
| "INCLUDES_BANNED"
| "INCLUDES_TIEBREAKER";
function validateCounterPickMapPool(
mapPool: MapPool,
isOneModeOnlyTournamentFor: ModeShort | null,
tieBreakerMapPool: TournamentData["ctx"]["tieBreakerMapPool"],
): CounterPickValidationStatus {
const stageCounts = new Map<StageId, number>();
for (const stageId of mapPool.stages) {
if (!stageCounts.has(stageId)) {
stageCounts.set(stageId, 0);
}
if (
stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT ||
(isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1)
) {
return "TOO_MUCH_STAGE_REPEAT";
}
stageCounts.set(stageId, stageCounts.get(stageId)! + 1);
}
if (
new MapPool(mapPool.serialized).stageModePairs.length !==
mapPool.stageModePairs.length
) {
return "STAGE_REPEAT_IN_SAME_MODE";
}
if (
mapPool.stageModePairs.some((pair) =>
BANNED_MAPS[pair.mode].includes(pair.stageId),
)
) {
return "INCLUDES_BANNED";
}
if (
mapPool.stageModePairs.some((pair) =>
tieBreakerMapPool.some(
(stage) => stage.mode === pair.mode && stage.stageId === pair.stageId,
),
)
) {
return "INCLUDES_TIEBREAKER";
}
if (
!isOneModeOnlyTournamentFor &&
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE)
) {
return "PICKING";
}
if (
isOneModeOnlyTournamentFor &&
mapPool.parsed[isOneModeOnlyTournamentFor].length !==
TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
) {
return "PICKING";
}
return "VALID";
}
function TOPickedMapPoolInfo() {
const { t } = useTranslation(["calendar"]);
const tournament = useTournament();

View File

@ -46,6 +46,9 @@ export const registerSchema = z.union([
z.object({
_action: _action("UNREGISTER"),
}),
z.object({
_action: _action("DELETE_LOGO"),
}),
]);
export const seedsActionSchema = z.object({
@ -109,6 +112,10 @@ export const adminActionSchema = z.union([
_action: _action("UNDO_DROP_TEAM_OUT"),
teamId: id,
}),
z.object({
_action: _action("DELETE_LOGO"),
teamId: id,
}),
z.object({
_action: _action("UPDATE_CAST_TWITCH_ACCOUNTS"),
castTwitchAccounts: z.preprocess(

View File

@ -1,10 +1,14 @@
import type { Params } from "@remix-run/react";
import invariant from "~/utils/invariant";
import type { Tournament } from "~/db/types";
import type { ModeShort } from "~/modules/in-game-lists";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { tournamentLogoUrl } from "~/utils/urls";
import type { PlayedSet } from "./core/sets.server";
import { MapPool } from "../map-list-generator/core/map-pool";
import type { TournamentData } from "../tournament-bracket/core/Tournament.server";
import { TOURNAMENT } from "./tournament-constants";
import { BANNED_MAPS } from "../sendouq-settings/banned-maps";
export function tournamentIdFromParams(params: Params<string>) {
const result = Number(params["id"]);
@ -307,3 +311,78 @@ export function HACKY_resolveThemeColors(event: { name: string }) {
return { backgroundColor: "#3430ad", textColor: WHITE };
}
export type CounterPickValidationStatus =
| "PICKING"
| "VALID"
| "TOO_MUCH_STAGE_REPEAT"
| "STAGE_REPEAT_IN_SAME_MODE"
| "INCLUDES_BANNED"
| "INCLUDES_TIEBREAKER";
export function validateCounterPickMapPool(
mapPool: MapPool,
isOneModeOnlyTournamentFor: ModeShort | null,
tieBreakerMapPool: TournamentData["ctx"]["tieBreakerMapPool"],
): CounterPickValidationStatus {
const stageCounts = new Map<StageId, number>();
for (const stageId of mapPool.stages) {
if (!stageCounts.has(stageId)) {
stageCounts.set(stageId, 0);
}
if (
stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT ||
(isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1)
) {
return "TOO_MUCH_STAGE_REPEAT";
}
stageCounts.set(stageId, stageCounts.get(stageId)! + 1);
}
if (
new MapPool(mapPool.serialized).stageModePairs.length !==
mapPool.stageModePairs.length
) {
return "STAGE_REPEAT_IN_SAME_MODE";
}
if (
mapPool.stageModePairs.some((pair) =>
BANNED_MAPS[pair.mode].includes(pair.stageId),
)
) {
return "INCLUDES_BANNED";
}
if (
mapPool.stageModePairs.some((pair) =>
tieBreakerMapPool.some(
(stage) => stage.mode === pair.mode && stage.stageId === pair.stageId,
),
)
) {
return "INCLUDES_TIEBREAKER";
}
if (
!isOneModeOnlyTournamentFor &&
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE)
) {
return "PICKING";
}
if (
isOneModeOnlyTournamentFor &&
mapPool.parsed[isOneModeOnlyTournamentFor].length !==
TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
) {
return "PICKING";
}
return "VALID";
}

View File

@ -952,6 +952,11 @@ dialog::backdrop {
height: unset;
}
.small-icon {
width: 1.2rem;
height: 1.2rem;
}
/* https://stackoverflow.com/questions/50917016/make-a-hidden-field-required/50917245#comment117565184_50917245 */
.hidden-input-with-validation {
position: absolute;

View File

@ -4,6 +4,14 @@ import type navItems from "~/components/layout/nav-items.json";
import { json } from "@remix-run/node";
import type { Namespace, TFunction } from "i18next";
import { noticeError } from "./newrelic.server";
import {
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
unstable_parseMultipartFormData as parseMultipartFormData,
} from "@remix-run/node";
import { s3UploadHandler } from "~/features/img-upload";
import { nanoid } from "nanoid";
import invariant from "./invariant";
export function notFoundIfFalsy<T>(value: T | null | undefined): T {
if (!value) throw new Response(null, { status: 404 });
@ -243,3 +251,41 @@ export function privatelyCachedJson<T>(data: T) {
headers: { "Cache-Control": "private, max-age=5" },
});
}
export async function uploadImageIfSubmitted({
request,
fileNamePrefix,
}: {
request: Request;
fileNamePrefix: string;
}) {
const uploadHandler = composeUploadHandlers(
s3UploadHandler(`${fileNamePrefix}-${nanoid()}-${Date.now()}`),
createMemoryUploadHandler(),
);
try {
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);
return {
avatarFileName: fileName,
formData,
};
} catch (err) {
// user did not submit image
if (err instanceof TypeError) {
return {
avatarFileName: undefined,
formData: await request.formData(),
};
}
throw err;
}
}

View File

@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "TournamentTeam" add "avatarImgId" integer`,
).run();
})();
}