diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index c9274acf2..ffb11f711 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -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(null); @@ -51,17 +54,23 @@ export function FormWithConfirm({ return ( <> - - {fields?.map(([name, value]) => ( - - ))} - + {isMounted + ? // using portal here makes nesting this component in another form work + createPortal( + + {fields?.map(([name, value]) => ( + + ))} + , + document.body, + ) + : null}

{dialogHeading}

@@ -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", })} ); diff --git a/app/db/tables.ts b/app/db/tables.ts index d0d3f927a..3ccf04d15 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -606,6 +606,7 @@ export interface TournamentTeam { >; tournamentId: number; teamId: number | null; + avatarImgId: number | null; } export interface TournamentTeamCheckIn { diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 00b6bc478..dcc193355 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -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) => { diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 158e82e77..af11529c4 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -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 | null; members: Array<{ diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 64ef33366..9ed4da4d8 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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()), diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 7d7765bce..ef4ba3e26 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -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(); } diff --git a/app/features/tournament-bracket/components/MatchRosters.tsx b/app/features/tournament-bracket/components/MatchRosters.tsx index 84b19f6a5..0b8f7f79a 100644 --- a/app/features/tournament-bracket/components/MatchRosters.tsx +++ b/app/features/tournament-bracket/components/MatchRosters.tsx @@ -40,7 +40,7 @@ export function MatchRosters({ return (
-
+
Team 1 @@ -48,6 +48,8 @@ export function MatchRosters({

{teamOne ? ( @@ -59,7 +61,7 @@ export function MatchRosters({ className="text-main-forced font-bold stack horizontal xs items-center" > {teamOneLogoSrc ? ( - + ) : null} {teamOne.name} @@ -92,12 +94,18 @@ export function MatchRosters({ ) : null}

-
+
Team 2
-

+

{teamTwo ? ( {teamTwoLogoSrc ? ( - + ) : null} {teamTwo.name} diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index f21064b73..6681ef9f5 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -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, }; }), diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index dcc1826f6..3d6ec0bf2 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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({ diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 7e39f60ad..43dcd0ba7 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -33,6 +33,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({ team: null, seed: 1, activeRosterUserIds: [], + pickupAvatarUrl: null, }); function summarize({ results }: { results?: AllMatchResult[] } = {}) { diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 310e57323..5f9a1aa50 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -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: [ { diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 6e4b93d34..45deee21d 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -17,6 +17,7 @@ const tournamentCtxTeam = ( mapPool: [], members: [], activeRosterUserIds: [], + pickupAvatarUrl: null, name: "Team " + teamId, prefersNotToHost: 0, droppedOut: 0, diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 21d9363bd..5d3aa3747 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -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; diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index a5f10a69d..a79a10726 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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") diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 0d43fd7c8..30b29e24d 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -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; + 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(); +} diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts new file mode 100644 index 000000000..fa8d8c301 --- /dev/null +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -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; +}; diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts new file mode 100644 index 000000000..fd568361f --- /dev/null +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -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; diff --git a/app/features/tournament/queries/createTeam.server.ts b/app/features/tournament/queries/createTeam.server.ts deleted file mode 100644 index cd46896eb..000000000 --- a/app/features/tournament/queries/createTeam.server.ts +++ /dev/null @@ -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, - }); - }, -); diff --git a/app/features/tournament/queries/updateTeamInfo.server.ts b/app/features/tournament/queries/updateTeamInfo.server.ts deleted file mode 100644 index d44ad4531..000000000 --- a/app/features/tournament/queries/updateTeamInfo.server.ts +++ /dev/null @@ -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, - }); -} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 664d4ff91..39518d26c 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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() { diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 323c4479e..e515d4a1e 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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(); + const data = useLoaderData(); const user = useUser(); const tournament = useTournament(); @@ -571,9 +339,7 @@ function RegistrationForms() { {user?.friendCode ? ( ) : 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(); + const data = useLoaderData(); 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(null); const [signUpWithTeam, setSignUpWithTeam] = React.useState(() => Boolean(tournament.ownedTeamByUser(user)?.team), ); + const [uploadedAvatar, setUploadedAvatar] = React.useState(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 (
@@ -829,7 +637,6 @@ function TeamInfo({ className="build__small-text" variant="minimal-destructive" size="tiny" - type="submit" > {t("tournament:pre.info.unregister")} @@ -837,7 +644,8 @@ function TeamInfo({ ) : null}
- +
+ {signUpWithTeam && data?.team ? ( ) : null} @@ -867,13 +675,48 @@ function TeamInfo({ readOnly={!tournament.registrationOpen || signUpWithTeam} />
+ {tournament.registrationOpen || avatarUrl ? ( +
+ + {avatarUrl ? ( +
+ + {canEditAvatar ? ( + + ) : null} + {canDeleteAvatar ? ( + + + + ) : null} +
+ ) : ( + + )} +
+ ) : null}
- {t("common:actions.save")} - - + +

); } +const logoDimensions = imgTypeToDimensions["team-pfp"]; +function TournamentLogoUpload({ + onChange, +}: { + onChange: (file: File | null) => void; +}) { + return ( + { + 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(); + const data = useLoaderData(); 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(); + const data = useLoaderData(); 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(); - 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(); diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 97988f894..414e7fe32 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -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( diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 2ac9ae620..8b8368e2c 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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) { 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(); + 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"; +} diff --git a/app/styles/common.css b/app/styles/common.css index aa0396c55..67035b2a6 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -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; diff --git a/app/utils/remix.ts b/app/utils/remix.ts index efb91ec7c..f6ac304af 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -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(value: T | null | undefined): T { if (!value) throw new Response(null, { status: 404 }); @@ -243,3 +251,41 @@ export function privatelyCachedJson(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; + } +} diff --git a/migrations/062-tournament-team-avatar.js b/migrations/062-tournament-team-avatar.js new file mode 100644 index 000000000..dbd63f5e0 --- /dev/null +++ b/migrations/062-tournament-team-avatar.js @@ -0,0 +1,7 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "TournamentTeam" add "avatarImgId" integer`, + ).run(); + })(); +}