diff --git a/app/components/RequiredHiddenInput.module.css b/app/components/RequiredHiddenInput.module.css deleted file mode 100644 index f99cb2609..000000000 --- a/app/components/RequiredHiddenInput.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.input { - position: absolute; - width: 0; - height: 0; - border: none; - opacity: 0; - pointer-events: none; -} diff --git a/app/components/RequiredHiddenInput.tsx b/app/components/RequiredHiddenInput.tsx deleted file mode 100644 index 9b02afbc7..000000000 --- a/app/components/RequiredHiddenInput.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import styles from "./RequiredHiddenInput.module.css"; - -export function RequiredHiddenInput({ - value, - isValid, - name, -}: { - value: string; - isValid: boolean; - name: string; -}) { - return ( - null} - required - /> - ); -} diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 4e5a313d6..dcc32868f 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -4,7 +4,6 @@ import type { CalendarEventTag } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import * as BadgeRepository from "~/features/badges/BadgeRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { newCalendarEventActionSchema } from "~/features/calendar/calendar-schemas.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { notify } from "~/features/notifications/core/notify.server"; @@ -12,6 +11,7 @@ import { clearTournamentDataCache, tournamentFromDB, } from "~/features/tournament-bracket/core/Tournament.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { requireRole } from "~/modules/permissions/guards.server"; import { @@ -22,33 +22,36 @@ import { badRequestIfFalsy, errorToast, errorToastIfFalsy, - parseFormData, - uploadImageIfSubmitted, } from "~/utils/remix.server"; +import { pathnameFromPotentialURL } from "~/utils/strings"; import { calendarEventPage } from "~/utils/urls"; import { CALENDAR_EVENT } from "../calendar-constants"; +import { calendarNewSchemaServer } from "../calendar-new-schemas.server"; import { canEditCalendarEvent, regClosesAtDate } from "../calendar-utils"; import { findValidOrganizations } from "../loaders/calendar.new.server"; export const action: ActionFunction = async ({ request }) => { const user = requireUser(); - const { avatarFileName, formData } = await uploadImageIfSubmitted({ + const result = await parseFormDataWithImages({ request, - fileNamePrefix: "tournament-logo", - }); - const data = await parseFormData({ - formData, - schema: newCalendarEventActionSchema, + schema: calendarNewSchemaServer, }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; const isEditing = Boolean(data.eventToEditId); const isAddingTournament = data.toToolsEnabled; + const organizationId = data.organizationId + ? Number(data.organizationId) + : null; - if (data.organizationId) { + if (organizationId) { await validateOrganization({ userId: user.id, - organizationId: data.organizationId, + organizationId, isTournamentAdder: user.roles.includes("TOURNAMENT_ADDER"), }); } else if (!isEditing) { @@ -59,69 +62,70 @@ export const action: ActionFunction = async ({ request }) => { const managedBadges = await BadgeRepository.findManagedByUserId(user.id); - const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date)); + const dates = + isAddingTournament && data.startTime ? [data.startTime] : data.date; + const startTimes = dates.map((date) => dateToDatabaseTimestamp(date)); const commonArgs = { authorId: user.id, - organizationId: data.organizationId ?? null, + organizationId, name: data.name, description: data.description, rules: data.rules, startTimes, - bracketUrl: data.bracketUrl, - discordInviteCode: data.discordInviteCode, - tags: data.tags - ? data.tags - .sort( - (a, b) => - CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) - - CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag), - ) - .join(",") - : data.tags, - badges: - data.badges?.filter((badge) => - managedBadges.some((mb) => mb.id === badge), - ) ?? [], - // newly uploaded avatar - avatarFileName, - // reused avatar either via edit or template + bracketUrl: data.bracketUrl || "https://sendou.ink", + discordInviteCode: data.discordInviteCode + ? pathnameFromPotentialURL(data.discordInviteCode) + : data.discordInviteCode, + tags: + data.tags.length > 0 + ? data.tags + .toSorted( + (a, b) => + CALENDAR_EVENT.TAGS.indexOf(a as CalendarEventTag) - + CALENDAR_EVENT.TAGS.indexOf(b as CalendarEventTag), + ) + .join(",") + : null, + badges: data.badges.filter((badge) => + managedBadges.some((mb) => mb.id === badge), + ), + // resolved by parseFormDataWithImages from the `image()` field avatarImgId: data.avatarImgId ?? undefined, - autoValidateAvatar: user.roles.includes("SUPPORTER"), toToolsEnabled: Number(data.toToolsEnabled), toToolsMode: rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, bracketProgression: data.bracketProgression ?? null, - minMembersPerTeam: data.minMembersPerTeam ?? undefined, - maxMembersPerTeam: data.maxMembersPerTeam ?? undefined, - isRanked: data.isRanked ?? undefined, - isTest: data.isTest ?? undefined, - isDraft: data.isDraft ?? undefined, - isInvitational: data.isInvitational ?? false, - enableNoScreenToggle: data.enableNoScreenToggle ?? undefined, - enableSubs: data.enableSubs ?? undefined, - requireInGameNames: data.requireInGameNames ?? undefined, - requireSendouQParticipation: data.requireSendouQParticipation ?? undefined, - autonomousSubs: data.autonomousSubs ?? undefined, + minMembersPerTeam: Number(data.minMembersPerTeam), + maxMembersPerTeam: + data.minMembersPerTeam === "4" && data.maxMembersPerTeam + ? data.maxMembersPerTeam + : undefined, + isRanked: data.isRanked, + isTest: data.isTest, + isDraft: data.isDraft, + isInvitational: data.isInvitational, + enableNoScreenToggle: data.enableNoScreenToggle, + enableSubs: data.enableSubs, + requireInGameNames: data.requireInGameNames, + requireSendouQParticipation: data.requireSendouQParticipation, + autonomousSubs: data.autonomousSubs, tournamentToCopyId: data.tournamentToCopyId, - regClosesAt: data.regClosesAt - ? dateToDatabaseTimestamp( - regClosesAtDate({ - startTime: databaseTimestampToDate(startTimes[0]), - closesAt: data.regClosesAt, - }), - ) - : undefined, + regClosesAt: + isAddingTournament && data.regClosesAt + ? dateToDatabaseTimestamp( + regClosesAtDate({ + startTime: databaseTimestampToDate(startTimes[0]), + closesAt: data.regClosesAt, + }), + ) + : undefined, }; errorToastIfFalsy( !commonArgs.toToolsEnabled || commonArgs.bracketProgression, "Bracket progression must be set for tournaments", ); - const deserializedMaps = (() => { - if (!data.pool) return; - - return MapPool.toDbList(data.pool); - })(); + const deserializedMaps = data.pool ? MapPool.toDbList(data.pool) : undefined; if (data.eventToEditId) { const eventToEdit = badRequestIfFalsy( diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index def725b17..252212559 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -64,7 +64,6 @@ export const CALENDAR_EVENT = { MAX_AMOUNT_OF_DATES: 5, /** Calendar event tag that is persisted in the database */ TAGS: Object.keys(tags) as Array, - AVATAR_SIZE: 512, }; export const REG_CLOSES_AT_OPTIONS = [ diff --git a/app/features/calendar/calendar-new-schemas.server.ts b/app/features/calendar/calendar-new-schemas.server.ts new file mode 100644 index 000000000..dbfd10b4b --- /dev/null +++ b/app/features/calendar/calendar-new-schemas.server.ts @@ -0,0 +1,8 @@ +import { + calendarNewBaseSchema, + calendarNewSyncRefine, +} from "./calendar-new-schemas"; + +export const calendarNewSchemaServer = calendarNewBaseSchema.superRefine( + calendarNewSyncRefine, +); diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts new file mode 100644 index 000000000..684c7bd2e --- /dev/null +++ b/app/features/calendar/calendar-new-schemas.ts @@ -0,0 +1,226 @@ +import { z } from "zod"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { + array, + badges, + checkboxGroup, + customField, + datetimeOptional, + datetimeRequired, + idConstantOptional, + image, + numberFieldOptional, + select, + selectDynamicOptional, + textAreaOptional, + textFieldOptional, + textFieldRequired, + toggle, +} from "~/form/fields"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; +import { bracketProgressionSchema } from "./calendar-schemas"; +import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; + +/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */ +const calendarEventDateField = datetimeRequired({ + label: "labels.date", + min: calendarEventMinDate, + max: calendarEventMaxDate, +}); + +export const calendarNewBaseSchema = z.object({ + // discriminates between a calendar event and a tournament; seeded from the loader, no visible control + toToolsEnabled: customField({ initialValue: false }, z.boolean()), // xxx: use "stringConstant" instead + eventToEditId: idConstantOptional(), + tournamentToCopyId: idConstantOptional(), + name: textFieldRequired({ + label: "labels.name", + minLength: CALENDAR_EVENT.NAME_MIN_LENGTH, + maxLength: CALENDAR_EVENT.NAME_MAX_LENGTH, + }), + description: textAreaOptional({ + label: "labels.description", + maxLength: CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH, + }), + organizationId: selectDynamicOptional({ label: "labels.organization" }), + rules: textAreaOptional({ + label: "labels.rules", + bottomText: "bottomTexts.bioMarkdown", + maxLength: CALENDAR_EVENT.RULES_MAX_LENGTH, + }), + // calendar events can span multiple dates; tournaments always have exactly one + // (`startTime`). Only the relevant field is rendered, and the other stays at its + // empty initial value — `calendarNewSyncRefine` enforces the right one per type. + date: array({ + label: "labels.dates", + max: CALENDAR_EVENT.MAX_AMOUNT_OF_DATES, + field: calendarEventDateField, + }), + startTime: datetimeOptional({ + label: "labels.date", + bottomText: "bottomTexts.tournamentStartTime", + min: calendarEventMinDate, + max: calendarEventMaxDate, + }), + bracketUrl: textFieldOptional({ + label: "labels.bracketUrl", + maxLength: CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH, + validate: "url", + }), + discordInviteCode: textFieldOptional({ + label: "labels.discordInvite", + maxLength: CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH, + leftAddon: "https://discord.gg/", + }), + tags: checkboxGroup({ + label: "labels.tags", + items: CALENDAR_EVENT.TAGS.map((tag) => ({ + value: tag, + label: `options.tag.${tag}` as const, + })), + }), + badges: badges({ label: "labels.badges", maxCount: 50 }), + avatarImgId: image({ + label: "labels.logo", + bottomText: "bottomTexts.avatarValidation", + autoValidate: true, + }), + regClosesAt: select({ + label: "labels.regClosesAt", + bottomText: "bottomTexts.regClosesAt", + items: REG_CLOSES_AT_OPTIONS.map((option) => ({ + value: option, + label: `options.regClosesAt.${option}` as const, + })), + }), + minMembersPerTeam: select({ + label: "labels.playersCount", + items: [4, 3, 2, 1].map((count) => ({ + value: String(count), + label: () => `${count}v${count}`, + })), + }), + maxMembersPerTeam: numberFieldOptional({ + label: "labels.maxTeamSize", + bottomText: "bottomTexts.maxTeamSize", + }), + toToolsMode: select({ + label: "labels.mapPickingStyle", + items: [ + { value: "ALL", label: "options.toToolsMode.ALL" }, + { value: "SZ", label: "options.toToolsMode.SZ" }, + { value: "TC", label: "options.toToolsMode.TC" }, + { value: "RM", label: "options.toToolsMode.RM" }, + { value: "CB", label: "options.toToolsMode.CB" }, + { value: "TO", label: "options.toToolsMode.TO" }, + ], + }), + pool: customField({ initialValue: "" }, z.string().optional()), + bracketProgression: customField( + { initialValue: null }, + bracketProgressionSchema.nullish(), + ), + isRanked: toggle({ + label: "labels.ranked", + bottomText: "bottomTexts.ranked", + }), + enableNoScreenToggle: toggle({ + label: "labels.splattercolorScreenToggle", + bottomText: "bottomTexts.splattercolorScreen", + }), + enableSubs: toggle({ + label: "labels.lfgTab", + bottomText: "bottomTexts.lfgTab", + }), + autonomousSubs: toggle({ + label: "labels.autonomousSubs", + bottomText: "bottomTexts.autonomousSubs", + }), + requireInGameNames: toggle({ + label: "labels.requireInGameNames", + bottomText: "bottomTexts.requireInGameNames", + }), + isInvitational: toggle({ + label: "labels.invitational", + bottomText: "bottomTexts.invitational", + }), + isTest: toggle({ label: "labels.test", bottomText: "bottomTexts.test" }), + isDraft: toggle({ + label: "labels.draft", + bottomText: "bottomTexts.draftInfo", + }), + requireSendouQParticipation: toggle({ + label: "labels.requireSendouQ", + bottomText: "bottomTexts.requireSendouQ", + }), +}); + +/** Shared sync cross-field rules, reused by the server schema (see `*.server.ts`). */ +export function calendarNewSyncRefine( + data: z.infer, + ctx: z.RefinementCtx, +) { + // a calendar event needs at least one date; a tournament needs its single start time + if (!data.toToolsEnabled && data.date.length < 1) { + ctx.addIssue({ + path: ["date"], + code: z.ZodIssueCode.custom, + message: "forms:errors.required", + }); + } + + if (data.toToolsEnabled && !data.startTime) { + ctx.addIssue({ + path: ["startTime"], + code: z.ZodIssueCode.custom, + message: "forms:errors.required", + }); + } + + // a calendar event needs a bracket URL; tournaments default to sendou.ink in the action + if (!data.toToolsEnabled && !data.bracketUrl) { + ctx.addIssue({ + path: ["bracketUrl"], + code: z.ZodIssueCode.custom, + message: "forms:errors.bracketUrlRequired", + }); + } + + if (data.toToolsEnabled && !data.bracketProgression) { + ctx.addIssue({ + path: ["bracketProgression"], + code: z.ZodIssueCode.custom, + message: "forms:errors.bracketProgressionRequired", + }); + } + + // "Prepicked by teams - All modes" requires one tiebreaker map per ranked mode + if (data.toToolsEnabled && data.toToolsMode === "ALL") { + const maps = data.pool ? MapPool.toDbList(data.pool) : []; + const isValid = + maps.length === rankedModesShort.length && + rankedModesShort.every((mode) => maps.some((map) => map.mode === mode)); + + if (!isValid) { + ctx.addIssue({ + path: ["pool"], + code: z.ZodIssueCode.custom, + message: "forms:errors.allModePool", + }); + } + } + + if ( + data.toToolsEnabled && + data.minMembersPerTeam === "4" && + data.maxMembersPerTeam && + (data.maxMembersPerTeam < 4 || data.maxMembersPerTeam > 10) + ) { + ctx.addIssue({ + path: ["maxMembersPerTeam"], + code: z.ZodIssueCode.custom, + message: "forms:errors.maxMembersRange", + }); + } +} diff --git a/app/features/calendar/calendar-new.module.css b/app/features/calendar/calendar-new.module.css deleted file mode 100644 index d3a842319..000000000 --- a/app/features/calendar/calendar-new.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.badges { - width: max-content; - padding: var(--s-2); - border-radius: var(--radius-box); - background-color: var(--color-bg-badge); - font-size: var(--font-sm); - font-weight: var(--weight-semi); -} - -:global(html.light) .badges { - color: var(--color-text-inverse); -} - -.dayLabel { - margin: 0; -} - -.avatarPreview { - width: 124px; - height: 124px; - border-radius: var(--radius-avatar); -} diff --git a/app/features/calendar/calendar-schemas.server.ts b/app/features/calendar/calendar-schemas.server.ts deleted file mode 100644 index 4e2b1b114..000000000 --- a/app/features/calendar/calendar-schemas.server.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { z } from "zod"; -import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; -import { - bracketProgressionSchema, - calendarEventTagSchema, -} from "~/features/calendar/calendar-schemas"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import { - actualNumber, - checkboxValueToBoolean, - date, - falsyToNull, - id, - processMany, - removeDuplicates, - safeJSONParse, - toArray, -} from "~/utils/zod"; -import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; -import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils"; - -export const newCalendarEventActionSchema = z - .object({ - eventToEditId: z.preprocess(actualNumber, id.nullish()), - tournamentToCopyId: z.preprocess(actualNumber, id.nullish()), - organizationId: z.preprocess(actualNumber, id.nullish()), - name: z - .string() - .min(CALENDAR_EVENT.NAME_MIN_LENGTH) - .max(CALENDAR_EVENT.NAME_MAX_LENGTH), - description: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH).nullable(), - ), - rules: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.RULES_MAX_LENGTH).nullable(), - ), - date: z.preprocess( - toArray, - z - .array( - z.preprocess( - date, - z.date().min(calendarEventMinDate()).max(calendarEventMaxDate()), - ), - ) - .min(1) - .max(CALENDAR_EVENT.MAX_AMOUNT_OF_DATES), - ), - bracketUrl: z - .string() - .url() - .max(CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH) - .default("https://sendou.ink"), - discordInviteCode: z.preprocess( - falsyToNull, - z.string().max(CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH).nullable(), - ), - tags: z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(calendarEventTagSchema).nullable(), - ), - badges: z.preprocess( - processMany(safeJSONParse, removeDuplicates), - z.array(id).nullable(), - ), - avatarImgId: id.nullish(), - pool: z.string().optional(), - toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()), - toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(), - isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - isDraft: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(), - enableNoScreenToggle: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish(), - ), - enableSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), - requireInGameNames: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish(), - ), - requireSendouQParticipation: z.preprocess( - checkboxValueToBoolean, - z.boolean().nullish(), - ), - minMembersPerTeam: z.preprocess( - actualNumber, - z.number().int().min(1).max(4).nullish(), - ), - maxMembersPerTeam: z.preprocess( - actualNumber, - z.number().int().min(4).max(10).nullish(), - ), - bracketProgression: bracketProgressionSchema.nullish(), - }) - .refine( - async (schema) => { - if (schema.eventToEditId) { - const eventToEdit = await CalendarRepository.findById( - schema.eventToEditId, - ); - return schema.date.length === 1 || !eventToEdit?.tournamentId; - } - return schema.date.length === 1 || !schema.toToolsEnabled; - }, - { - message: "Tournament must have exactly one date", - }, - ) - .refine( - (schema) => { - if (schema.toToolsMode !== "ALL") { - return true; - } - - const maps = schema.pool ? MapPool.toDbList(schema.pool) : []; - - return ( - maps.length === 4 && - rankedModesShort.every((mode) => maps.some((map) => map.mode === mode)) - ); - }, - { - message: - 'Map pool must contain a map for each ranked mode if using "Prepicked by teams - All modes"', - }, - ); diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index a08da1c9f..9784c8315 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -25,7 +25,7 @@ import { import { CALENDAR_EVENT, CALENDAR_EVENT_RESULT } from "./calendar-constants"; import * as CalendarEvent from "./core/CalendarEvent"; -export const calendarEventTagSchema = z +const calendarEventTagSchema = z .string() .refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)); diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index 65593e517..d4b3da1df 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -25,12 +25,13 @@ const defaultBracket = (): Progression.InputBracket => ({ export function BracketProgressionSelector({ initialBrackets, isInvitationalTournament, - setErrored, + onChange, isTournamentInProgress, }: { initialBrackets?: Progression.InputBracket[]; isInvitationalTournament: boolean; - setErrored: (errored: boolean) => void; + /** Emits the validated brackets while valid, or `null` while invalid/incomplete. */ + onChange: (value: Progression.ParsedBracket[] | null) => void; isTournamentInProgress: boolean; }) { const [brackets, setBrackets] = React.useState( @@ -75,24 +76,21 @@ export function BracketProgressionSelector({ }; const validated = Progression.validatedBrackets(brackets); + // `validatedBrackets` returns a fresh array each render, so emit only when the + // serialized result actually changes — otherwise `onChange` would loop the form store. + const serialized = Progression.isBrackets(validated) + ? JSON.stringify(validated) + : null; + const lastSerialized = React.useRef(undefined); React.useEffect(() => { - if (Progression.isError(validated)) { - setErrored(true); - } else { - setErrored(false); - } - }, [validated, setErrored]); + if (lastSerialized.current === serialized) return; + lastSerialized.current = serialized; + onChange(serialized ? JSON.parse(serialized) : null); + }, [serialized, onChange]); return (
- {Progression.isBrackets(validated) ? ( - - ) : null}
{brackets.map((bracket, i) => ( { } : undefined; + // the badges the user can pick from, plus any already-attached prize badges they no + // longer manage (so an existing selection still renders and stays removable) + const badgeOptions = R.uniqueBy( + [...managedBadges, ...(eventToEdit?.badgePrizes ?? [])].map((badge) => ({ + id: badge.id, + code: badge.code, + displayName: badge.displayName, + hue: badge.hue, + })), + (badge) => badge.id, + ); + return { isAddingTournament: Boolean( url.searchParams.has("tournament") || @@ -100,6 +112,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { eventToEdit?.tournament, ), managedBadges, + badgeOptions, eventToEdit: canEditEvent ? eventToEdit : undefined, eventToCopy, recentTournaments: diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index d0a8107bc..4b7e27bde 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -1,57 +1,34 @@ -import clsx from "clsx"; -import Compressor from "compressorjs"; -import { Trash, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, Link, useFetcher, useLoaderData } from "react-router"; +import { Form, Link, useLoaderData } from "react-router"; import type { AlertVariation } from "~/components/Alert"; import { Alert } from "~/components/Alert"; -import { Badge } from "~/components/Badge"; -import { DateInput } from "~/components/DateInput"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; -import { SendouSwitch } from "~/components/elements/Switch"; import { FormMessage } from "~/components/FormMessage"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { MapPoolSelector } from "~/components/MapPoolSelector"; -import { RequiredHiddenInput } from "~/components/RequiredHiddenInput"; import { SubmitButton } from "~/components/SubmitButton"; -import type { CalendarEventTag, Tables } from "~/db/tables"; +import type { Tables } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; +import { existingImage } from "~/form/image-field"; +import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import { errorMessageId } from "~/form/utils"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; -import { useHydrated } from "~/hooks/useHydrated"; import type { RankedModeShort } from "~/modules/in-game-lists/types"; import { useHasRole } from "~/modules/permissions/hooks"; -import { - databaseTimestampToDate, - getDateAtNextFullHour, - getDateWithHoursOffset, -} from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; +import { databaseTimestampToDate, getDateAtNextFullHour } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { pathnameFromPotentialURL } from "~/utils/strings"; import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls"; import { action } from "../actions/calendar.new.server"; -import { - CALENDAR_EVENT, - REG_CLOSES_AT_OPTIONS, - type RegClosesAtOption, -} from "../calendar-constants"; -import styles from "../calendar-new.module.css"; -import { - calendarEventMaxDate, - calendarEventMinDate, - datesToRegClosesAt, - regClosesAtToDisplayName, -} from "../calendar-utils"; +import type { RegClosesAtOption } from "../calendar-constants"; +import { calendarNewBaseSchema } from "../calendar-new-schemas"; +import { datesToRegClosesAt } from "../calendar-utils"; import { BracketProgressionSelector } from "../components/BracketProgressionSelector"; -import { Tags } from "../components/Tags"; import { loader } from "../loaders/calendar.new.server"; export { action, loader }; @@ -71,6 +48,18 @@ export const handle: SendouRouteHandle = { i18n: ["calendar", "game-misc", "tournament"], }; +const mapPickingStyleToShort: Record< + Tables["Tournament"]["mapPickingStyle"], + "ALL" | "TO" | RankedModeShort +> = { + TO: "TO", + AUTO_ALL: "ALL", + AUTO_SZ: "SZ", + AUTO_TC: "TC", + AUTO_RM: "RM", + AUTO_CB: "CB", +}; + const useBaseEvent = () => { const { eventToEdit, eventToCopy } = useLoaderData(); @@ -81,6 +70,7 @@ export default function CalendarNewEventPage() { const baseEvent = useBaseEvent(); const isCalendarEventAdder = useHasRole("CALENDAR_EVENT_ADDER"); const data = useLoaderData(); + const defaultValues = useDefaultValues(); if (!data.eventToEdit && !isCalendarEventAdder) { return ( @@ -128,12 +118,106 @@ export default function CalendarNewEventPage() { ) : null}
{data.isAddingTournament ? : null} - + + +
); } +function useDefaultValues() { + const data = useLoaderData(); + const baseEvent = useBaseEvent(); + const tournamentCtx = baseEvent?.tournament?.ctx; + const settings = tournamentCtx?.settings; + + const regClosesAt: RegClosesAtOption = tournamentCtx?.settings.regClosesAt + ? datesToRegClosesAt({ + startTime: databaseTimestampToDate(tournamentCtx.startTime), + regClosesAt: databaseTimestampToDate( + tournamentCtx.settings.regClosesAt, + ), + }) + : "0"; + + const toToolsMode = baseEvent?.mapPickingStyle + ? mapPickingStyleToShort[baseEvent.mapPickingStyle] + : "ALL"; + + const pool = (() => { + if (!baseEvent) return ""; + if (!data.isAddingTournament || toToolsMode === "TO") { + return baseEvent.mapPool ? new MapPool(baseEvent.mapPool).serialized : ""; + } + if (toToolsMode === "ALL") { + return baseEvent.tieBreakerMapPool + ? new MapPool(baseEvent.tieBreakerMapPool).serialized + : ""; + } + return ""; + })(); + + return { + toToolsEnabled: data.isAddingTournament, + eventToEditId: data.eventToEdit?.eventId, + tournamentToCopyId: data.eventToCopy?.tournamentId ?? undefined, + name: data.eventToEdit?.name ?? "", + description: baseEvent?.description ?? "", + organizationId: baseEvent?.organization?.id + ? String(baseEvent.organization.id) + : null, + rules: baseEvent?.rules ?? "", + date: data.isAddingTournament + ? [] + : (data.eventToEdit?.startTimes?.map((t) => + databaseTimestampToDate(t), + ) ?? [getDateAtNextFullHour(new Date())]), + startTime: data.isAddingTournament + ? data.eventToEdit?.startTimes?.[0] + ? databaseTimestampToDate(data.eventToEdit.startTimes[0]) + : getDateAtNextFullHour(new Date()) + : null, + // tournaments hide this field, so seed a valid URL to satisfy the url-format + // validation (the action coalesces to the same default) + bracketUrl: data.isAddingTournament + ? "https://sendou.ink" + : (data.eventToEdit?.bracketUrl ?? ""), + discordInviteCode: baseEvent?.discordInviteCode ?? "", + tags: baseEvent?.tags ?? [], + badges: baseEvent?.badgePrizes?.map((b) => b.id) ?? [], + avatarImgId: existingImage( + baseEvent?.avatarImgId, + baseEvent?.tournament?.ctx.logoUrl, + ), + regClosesAt, + minMembersPerTeam: String(settings?.minMembersPerTeam ?? 4) as + | "1" + | "2" + | "3" + | "4", + maxMembersPerTeam: settings?.maxMembersPerTeam ?? undefined, + toToolsMode, + pool, + bracketProgression: settings?.bracketProgression ?? null, + isRanked: settings?.isRanked ?? true, + enableNoScreenToggle: settings?.enableNoScreenToggle ?? true, + enableSubs: settings?.enableSubs ?? true, + autonomousSubs: settings?.autonomousSubs ?? true, + requireInGameNames: settings?.requireInGameNames ?? false, + isInvitational: settings?.isInvitational ?? false, + isTest: settings?.isTest ?? false, + isDraft: settings?.isDraft ?? false, + requireSendouQParticipation: settings?.requireSendouQParticipation ?? false, + }; +} + function TemplateTournamentForm() { const { recentTournaments } = useLoaderData(); const [eventId, setEventId] = React.useState(""); @@ -161,7 +245,9 @@ function TemplateTournamentForm() { ))} - Use template + + Use template +
@@ -169,1046 +255,286 @@ function TemplateTournamentForm() { ); } -function EventForm() { - const fetcher = useFetcher(); - const { t } = useTranslation(); - const { eventToEdit, eventToCopy } = useLoaderData(); - const ref = React.useRef(null); - const [avatarImg, setAvatarImg] = React.useState(null); - const baseEvent = useBaseEvent(); - const [isInvitational, setIsInvitational] = React.useState( - baseEvent?.tournament?.ctx.settings.isInvitational ?? false, - ); +function CalendarNewFields() { const data = useLoaderData(); - const [bracketProgressionErrored, setBracketProgressionErrored] = - React.useState(false); - - const handleSubmit = () => { - const isValid = ref.current?.checkValidity(); - if (!isValid) { - ref.current?.reportValidity(); - return; - } - - const formData = new FormData(ref.current!); - - // if "avatarImgId" it means they want to reuse an existing avatar - const includeImage = avatarImg && !formData.has("avatarImgId"); - - if (includeImage) { - // replace with the compressed version - formData.delete("img"); - formData.append("img", avatarImg, avatarImg.name); - } - - fetcher.submit(formData, { - encType: includeImage ? "multipart/form-data" : undefined, - method: "post", - }); - }; - - const submitButtonDisabled = () => { - if (fetcher.state !== "idle") return true; - if (bracketProgressionErrored) return true; - - return false; - }; - - return ( -
- {eventToEdit && ( - - )} - {eventToCopy?.tournamentId ? ( - - ) : null} - {data.isAddingTournament ? ( - - ) : null} - - - - {data.isAddingTournament ? : null} - - {!data.isAddingTournament ? : null} - - - - {data.isAddingTournament ? ( - - ) : null} - {data.isAddingTournament ? ( - <> - Tournament settings - - - - - - - - - {!eventToEdit ? : null} - - - - ) : null} - {data.isAddingTournament ? ( - - ) : ( - - )} - {data.isAddingTournament ? ( -
- Tournament format - -
- ) : null} - - {t("actions.submit")} - - - ); -} - -function NameInput() { - const { t } = useTranslation(); - const { eventToEdit } = useLoaderData(); - - return ( -
- - -
- ); -} - -function DescriptionTextarea({ - supportsMarkdown, -}: { - supportsMarkdown?: boolean; -}) { - const { t } = useTranslation(); - const baseEvent = useBaseEvent(); - const [value, setValue] = React.useState(baseEvent?.description ?? ""); - - return ( -
- -