mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
1282 lines
33 KiB
TypeScript
1282 lines
33 KiB
TypeScript
import type { MetaFunction } from "@remix-run/node";
|
|
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import Compressor from "compressorjs";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 { FormMessage } from "~/components/FormMessage";
|
|
import { Input } from "~/components/Input";
|
|
import { CrossIcon } from "~/components/icons/Cross";
|
|
import { TrashIcon } from "~/components/icons/Trash";
|
|
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 { MapPool } from "~/features/map-list-generator/core/map-pool";
|
|
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import type { RankedModeShort } from "~/modules/in-game-lists/types";
|
|
import {
|
|
databaseTimestampToDate,
|
|
getDateAtNextFullHour,
|
|
getDateWithHoursOffset,
|
|
} from "~/utils/dates";
|
|
import invariant from "~/utils/invariant";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import { pathnameFromPotentialURL } from "~/utils/strings";
|
|
import { CREATING_TOURNAMENT_DOC_LINK } from "~/utils/urls";
|
|
import { userSubmittedImage } from "~/utils/urls-img";
|
|
import {
|
|
CALENDAR_EVENT,
|
|
REG_CLOSES_AT_OPTIONS,
|
|
type RegClosesAtOption,
|
|
} from "../calendar-constants";
|
|
import {
|
|
calendarEventMaxDate,
|
|
calendarEventMinDate,
|
|
datesToRegClosesAt,
|
|
regClosesAtToDisplayName,
|
|
} from "../calendar-utils";
|
|
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
|
|
import { Tags } from "../components/Tags";
|
|
import "~/styles/calendar-new.css";
|
|
import { SendouSwitch } from "~/components/elements/Switch";
|
|
import { useHasRole } from "~/modules/permissions/hooks";
|
|
import { logger } from "~/utils/logger";
|
|
import { metaTags } from "~/utils/remix";
|
|
import { action } from "../actions/calendar.new.server";
|
|
import { loader } from "../loaders/calendar.new.server";
|
|
export { loader, action };
|
|
|
|
export const meta: MetaFunction<typeof loader> = (args) => {
|
|
if (!args.data) return [];
|
|
|
|
const what = args.data.isAddingTournament ? "tournament" : "calendar event";
|
|
|
|
return metaTags({
|
|
title: args.data.eventToEdit ? `Editing ${what}` : `New ${what}`,
|
|
location: args.location,
|
|
});
|
|
};
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["calendar", "game-misc", "tournament"],
|
|
};
|
|
|
|
const useBaseEvent = () => {
|
|
const { eventToEdit, eventToCopy } = useLoaderData<typeof loader>();
|
|
|
|
return eventToCopy ?? eventToEdit;
|
|
};
|
|
|
|
export default function CalendarNewEventPage() {
|
|
const baseEvent = useBaseEvent();
|
|
const isCalendarEventAdder = useHasRole("CALENDAR_EVENT_ADDER");
|
|
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
if (!data.eventToEdit && !isCalendarEventAdder) {
|
|
return (
|
|
<Main className="stack items-center">
|
|
<Alert variation="WARNING">
|
|
You can't add a new event at this time (Discord account too young)
|
|
</Alert>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
if (!data.eventToEdit && data.isAddingTournament && !isTournamentAdder) {
|
|
return (
|
|
<Main className="stack items-center">
|
|
<Alert variation="WARNING">
|
|
No permissions to add tournaments. Tournaments are in beta, accessible
|
|
by Patreon supporters and established TO's.
|
|
</Alert>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Main className="calendar-new__container">
|
|
<div className="stack md">
|
|
<div className="stack horizontal md items-center">
|
|
<h1 className="text-lg">
|
|
{data.isAddingTournament ? "New tournament" : "New calendar event"}
|
|
</h1>
|
|
{data.isAddingTournament ? (
|
|
<a
|
|
href={CREATING_TOURNAMENT_DOC_LINK}
|
|
className="text-lg text-bold"
|
|
title="Documentation about creating tournaments"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
?
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
{data.isAddingTournament ? <TemplateTournamentForm /> : null}
|
|
<EventForm key={baseEvent?.eventId} />
|
|
</div>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function TemplateTournamentForm() {
|
|
const { recentTournaments } = useLoaderData<typeof loader>();
|
|
const [eventId, setEventId] = React.useState("");
|
|
|
|
if (!recentTournaments) return null;
|
|
|
|
return (
|
|
<>
|
|
<div>
|
|
<Form className="stack horizontal sm flex-wrap">
|
|
<select
|
|
className="w-max"
|
|
name="copyEventId"
|
|
onChange={(event) => {
|
|
setEventId(event.target.value);
|
|
}}
|
|
>
|
|
<option value="">Select a template</option>
|
|
{recentTournaments.map((event) => (
|
|
<option key={event.id} value={event.id} suppressHydrationWarning>
|
|
{event.name} (
|
|
{databaseTimestampToDate(event.startTime).toLocaleDateString(
|
|
"en-US",
|
|
{ month: "numeric", day: "numeric" },
|
|
)}
|
|
)
|
|
</option>
|
|
))}
|
|
</select>
|
|
<SubmitButton isDisabled={!eventId}>Use template</SubmitButton>
|
|
</Form>
|
|
</div>
|
|
<hr />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function EventForm() {
|
|
const fetcher = useFetcher();
|
|
const { t } = useTranslation();
|
|
const { eventToEdit, eventToCopy } = useLoaderData<typeof loader>();
|
|
const ref = React.useRef<HTMLFormElement>(null);
|
|
const [avatarImg, setAvatarImg] = React.useState<File | null>(null);
|
|
const baseEvent = useBaseEvent();
|
|
const [isInvitational, setIsInvitational] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.isInvitational ?? false,
|
|
);
|
|
const data = useLoaderData<typeof loader>();
|
|
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 (
|
|
<Form className="stack md items-start" ref={ref}>
|
|
{eventToEdit && (
|
|
<input type="hidden" name="eventToEditId" value={eventToEdit.eventId} />
|
|
)}
|
|
{eventToCopy?.tournamentId ? (
|
|
<input
|
|
type="hidden"
|
|
name="tournamentToCopyId"
|
|
value={eventToCopy.tournamentId}
|
|
/>
|
|
) : null}
|
|
{data.isAddingTournament ? (
|
|
<input type="hidden" name="toToolsEnabled" value="on" />
|
|
) : null}
|
|
<NameInput />
|
|
<DescriptionTextarea supportsMarkdown={data.isAddingTournament} />
|
|
<OrganizationSelect />
|
|
{data.isAddingTournament ? <RulesTextarea supportsMarkdown /> : null}
|
|
<DatesInput allowMultiDate={!data.isAddingTournament} />
|
|
{!data.isAddingTournament ? <BracketUrlInput /> : null}
|
|
<DiscordLinkInput />
|
|
<TagsAdder />
|
|
<BadgesAdder />
|
|
{data.isAddingTournament ? (
|
|
<AvatarImageInput avatarImg={avatarImg} setAvatarImg={setAvatarImg} />
|
|
) : null}
|
|
{data.isAddingTournament ? (
|
|
<>
|
|
<Divider>Tournament settings</Divider>
|
|
<MemberCountSelect />
|
|
<RegClosesAtSelect />
|
|
<RankedToggle />
|
|
<EnableNoScreenToggle />
|
|
<EnableSubsToggle />
|
|
<AutonomousSubsToggle />
|
|
<RequireIGNToggle />
|
|
<InvitationalToggle
|
|
isInvitational={isInvitational}
|
|
setIsInvitational={setIsInvitational}
|
|
/>
|
|
<StrictDeadlinesToggle />
|
|
{!eventToEdit ? <TestToggle /> : null}
|
|
</>
|
|
) : null}
|
|
{data.isAddingTournament ? (
|
|
<TournamentMapPickingStyleSelect />
|
|
) : (
|
|
<MapPoolSection />
|
|
)}
|
|
{data.isAddingTournament ? (
|
|
<div className="stack md w-full">
|
|
<Divider>Tournament format</Divider>
|
|
<BracketProgressionSelector
|
|
initialBrackets={
|
|
baseEvent?.tournament?.ctx.settings.bracketProgression
|
|
? Progression.validatedBracketsToInputFormat(
|
|
baseEvent.tournament?.ctx.settings.bracketProgression,
|
|
)
|
|
: undefined
|
|
}
|
|
isInvitationalTournament={isInvitational}
|
|
setErrored={setBracketProgressionErrored}
|
|
isTournamentInProgress={false}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
<SendouButton
|
|
className="mt-4"
|
|
onPress={handleSubmit}
|
|
isDisabled={submitButtonDisabled()}
|
|
data-testid="submit-button"
|
|
>
|
|
{t("actions.submit")}
|
|
</SendouButton>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
function NameInput() {
|
|
const { t } = useTranslation();
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="name" required>
|
|
{t("forms.name")}
|
|
</Label>
|
|
<input
|
|
name="name"
|
|
id="name"
|
|
required
|
|
minLength={CALENDAR_EVENT.NAME_MIN_LENGTH}
|
|
maxLength={CALENDAR_EVENT.NAME_MAX_LENGTH}
|
|
defaultValue={eventToEdit?.name}
|
|
data-testid="calendar-event-name-input"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DescriptionTextarea({
|
|
supportsMarkdown,
|
|
}: {
|
|
supportsMarkdown?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const baseEvent = useBaseEvent();
|
|
const [value, setValue] = React.useState(baseEvent?.description ?? "");
|
|
|
|
return (
|
|
<div>
|
|
<Label
|
|
htmlFor="description"
|
|
valueLimits={{
|
|
current: value.length,
|
|
max: CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH,
|
|
}}
|
|
>
|
|
{t("forms.description")}
|
|
</Label>
|
|
<textarea
|
|
id="description"
|
|
name="description"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
maxLength={CALENDAR_EVENT.DESCRIPTION_MAX_LENGTH}
|
|
/>
|
|
{supportsMarkdown ? (
|
|
<FormMessage type="info">Supports Markdown</FormMessage>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OrganizationSelect() {
|
|
const id = React.useId();
|
|
const data = useLoaderData<typeof loader>();
|
|
const baseEvent = useBaseEvent();
|
|
|
|
if (data.organizations.length === 0) return null;
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor={id}>Organization</Label>
|
|
<select
|
|
id={id}
|
|
name="organizationId"
|
|
defaultValue={baseEvent?.organization?.id}
|
|
>
|
|
<option>Select an organization</option>
|
|
{data.organizations.map((org) => (
|
|
<option key={org.id} value={org.id}>
|
|
{org.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RulesTextarea({ supportsMarkdown }: { supportsMarkdown?: boolean }) {
|
|
const baseEvent = useBaseEvent();
|
|
const [value, setValue] = React.useState(
|
|
baseEvent?.tournament?.ctx.rules ?? "",
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<Label
|
|
htmlFor="rules"
|
|
valueLimits={{
|
|
current: value.length,
|
|
max: CALENDAR_EVENT.RULES_MAX_LENGTH,
|
|
}}
|
|
>
|
|
Rules
|
|
</Label>
|
|
<textarea
|
|
id="rules"
|
|
name="rules"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
maxLength={CALENDAR_EVENT.RULES_MAX_LENGTH}
|
|
/>
|
|
{supportsMarkdown ? (
|
|
<FormMessage type="info">Supports Markdown</FormMessage>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddButton({ onAdd, id }: { onAdd: () => void; id?: string }) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<SendouButton size="small" variant="outlined" onPress={onAdd} id={id}>
|
|
{t("actions.add")}
|
|
</SendouButton>
|
|
);
|
|
}
|
|
|
|
function DatesInput({ allowMultiDate }: { allowMultiDate?: boolean }) {
|
|
const { t } = useTranslation(["common", "calendar"]);
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
// Using array index as a key can mess up internal state, especially when
|
|
// removing elements from the middle. So we just count up for every date we
|
|
// create.
|
|
const keyCounter = React.useRef(0);
|
|
const getKey = () => ++keyCounter.current;
|
|
|
|
// React hook that keeps track of child DateInput's dates
|
|
// (necessary for determining additional Date's defaultValues)
|
|
const [_datesInputState, setDatesInputState] = React.useState<
|
|
Array<{
|
|
key: number;
|
|
date: Date | null;
|
|
}>
|
|
>(() => {
|
|
// Initialize datesInputState by retrieving pre-existing events if they exist
|
|
if (eventToEdit?.startTimes) {
|
|
return eventToEdit.startTimes.map((t) => ({
|
|
key: getKey(),
|
|
date: databaseTimestampToDate(t),
|
|
}));
|
|
}
|
|
|
|
// Initial date rounded to next full hour from now
|
|
return [{ key: getKey(), date: getDateAtNextFullHour(new Date()) }];
|
|
});
|
|
|
|
const datesInputState = allowMultiDate
|
|
? _datesInputState
|
|
: _datesInputState.filter((_, i) => i === 0);
|
|
|
|
const datesCount = datesInputState.length;
|
|
|
|
const isMounted = useIsMounted();
|
|
const usersTimeZone = isMounted
|
|
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
: "";
|
|
const NEW_CALENDAR_EVENT_HOURS_OFFSET = 24;
|
|
|
|
const addDate = () =>
|
|
setDatesInputState((current) => {
|
|
// .reverse() is mutating, but map/filter returns a new array anyway.
|
|
const lastValidDate = current
|
|
.map((e) => e.date)
|
|
.filter((date) => date !== null)
|
|
.reverse()[0];
|
|
|
|
const addedDate = lastValidDate
|
|
? getDateWithHoursOffset(lastValidDate, NEW_CALENDAR_EVENT_HOURS_OFFSET)
|
|
: getDateAtNextFullHour(new Date());
|
|
|
|
return [...current, { key: getKey(), date: addedDate }];
|
|
});
|
|
|
|
return (
|
|
<div className="stack md items-start">
|
|
<fieldset>
|
|
<legend>
|
|
{t("calendar:forms.dates")} <span className="text-error">*</span>
|
|
</legend>
|
|
<div className="stack sm items-start">
|
|
<div className="stack sm">
|
|
{datesInputState.map(({ date, key }, i) => {
|
|
return (
|
|
<div key={key} className="stack horizontal sm items-center">
|
|
<label
|
|
id={`date-input-${key}-label`}
|
|
className="calendar-new__day-label"
|
|
htmlFor={`date-input-${key}`}
|
|
>
|
|
{t("calendar:day", {
|
|
number: i + 1,
|
|
})}
|
|
</label>
|
|
<DateInput
|
|
id={`date-input-${key}`}
|
|
name="date"
|
|
suppressHydrationWarning
|
|
defaultValue={eventToEdit && date ? date : undefined}
|
|
min={calendarEventMinDate()}
|
|
max={calendarEventMaxDate()}
|
|
required
|
|
onChange={(newDate: Date | null) => {
|
|
setDatesInputState((current) =>
|
|
current.map((entry) =>
|
|
entry.key === key
|
|
? { ...entry, date: newDate }
|
|
: entry,
|
|
),
|
|
);
|
|
}}
|
|
/>
|
|
{/* "Remove" button */}
|
|
{datesCount > 1 && (
|
|
<SendouButton
|
|
size="small"
|
|
onPress={() => {
|
|
setDatesInputState((current) =>
|
|
current.filter((e) => e.key !== key),
|
|
);
|
|
}}
|
|
aria-controls={`date-input-${key}`}
|
|
aria-label={t("common:actions.remove")}
|
|
aria-describedby={`date-input-${key}-label`}
|
|
icon={<CrossIcon title={t("common:actions.remove")} />}
|
|
variant="minimal-destructive"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{datesCount < CALENDAR_EVENT.MAX_AMOUNT_OF_DATES &&
|
|
allowMultiDate && <AddButton onAdd={addDate} />}
|
|
<FormMessage type="info" className={clsx({ invisible: !isMounted })}>
|
|
{t("calendar:inYourTimeZone")} {usersTimeZone}
|
|
</FormMessage>
|
|
</div>
|
|
</fieldset>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BracketUrlInput() {
|
|
const { t } = useTranslation("calendar");
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="bracketUrl" required>
|
|
{t("forms.bracketUrl")}
|
|
</Label>
|
|
<input
|
|
name="bracketUrl"
|
|
type="url"
|
|
required
|
|
maxLength={CALENDAR_EVENT.BRACKET_URL_MAX_LENGTH}
|
|
defaultValue={eventToEdit?.bracketUrl}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiscordLinkInput() {
|
|
const { t } = useTranslation("calendar");
|
|
const baseEvent = useBaseEvent();
|
|
const [value, setValue] = React.useState(baseEvent?.discordInviteCode ?? "");
|
|
|
|
return (
|
|
<div className="stack items-start">
|
|
<Label htmlFor="discordInviteCode">{t("forms.discordInvite")}</Label>
|
|
<Input
|
|
name="discordInviteCode"
|
|
leftAddon="https://discord.gg/"
|
|
maxLength={CALENDAR_EVENT.DISCORD_INVITE_CODE_MAX_LENGTH}
|
|
value={value}
|
|
onChange={(e) => setValue(pathnameFromPotentialURL(e.target.value))}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TagsAdder() {
|
|
const { t } = useTranslation(["common", "calendar"]);
|
|
const baseEvent = useBaseEvent();
|
|
const [tags, setTags] = React.useState(baseEvent?.tags ?? []);
|
|
const id = React.useId();
|
|
|
|
const tagsForSelect = CALENDAR_EVENT.TAGS.filter(
|
|
(tag) => !tags.includes(tag),
|
|
).filter((tag) => tag !== "SZ" && tag !== "TW"); // TODO: these are now added automatically, remove in migration?
|
|
|
|
return (
|
|
<div className="stack sm">
|
|
<input
|
|
type="hidden"
|
|
name="tags"
|
|
value={JSON.stringify(tags.length > 0 ? tags : null)}
|
|
/>
|
|
<div>
|
|
<label htmlFor={id}>{t("calendar:forms.tags")}</label>
|
|
<select
|
|
id={id}
|
|
className="calendar-new__select"
|
|
onChange={(e) =>
|
|
setTags([...tags, e.target.value as CalendarEventTag])
|
|
}
|
|
>
|
|
<option value="">{t("calendar:forms.tags.placeholder")}</option>
|
|
{tagsForSelect.map((tag) => (
|
|
<option key={tag} value={tag}>
|
|
{t(`common:tag.name.${tag}`)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<Tags
|
|
tags={tags}
|
|
onDelete={(tagToDelete) =>
|
|
setTags(tags.filter((tag) => tag !== tagToDelete))
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BadgesAdder() {
|
|
const { t } = useTranslation("calendar");
|
|
const baseEvent = useBaseEvent();
|
|
const { managedBadges } = useLoaderData<typeof loader>();
|
|
const [badges, setBadges] = React.useState(baseEvent?.badgePrizes ?? []);
|
|
const id = React.useId();
|
|
|
|
const input = (
|
|
<input
|
|
type="hidden"
|
|
name="badges"
|
|
value={JSON.stringify(badges.length > 0 ? badges.map((b) => b.id) : null)}
|
|
/>
|
|
);
|
|
|
|
if (managedBadges.length === 0) return input;
|
|
|
|
const handleBadgeDelete = (badgeId: Tables["Badge"]["id"]) => {
|
|
setBadges(badges.filter((badge) => badge.id !== badgeId));
|
|
};
|
|
|
|
const badgesForSelect = managedBadges.filter(
|
|
(badge) => !badges.some((b) => b.id === badge.id),
|
|
);
|
|
|
|
return (
|
|
<div className="stack md">
|
|
{input}
|
|
<div>
|
|
<label htmlFor={id}>{t("forms.badges")}</label>
|
|
<select
|
|
id={id}
|
|
className="calendar-new__select"
|
|
onChange={(e) => {
|
|
setBadges([
|
|
...badges,
|
|
managedBadges.find(
|
|
(badge) => badge.id === Number(e.target.value),
|
|
)!,
|
|
]);
|
|
}}
|
|
>
|
|
<option value="">{t("forms.badges.placeholder")}</option>
|
|
{badgesForSelect.map((badge) => (
|
|
<option key={badge.id} value={badge.id}>
|
|
{badge.displayName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{badges.length > 0 && (
|
|
<div className="calendar-new__badges">
|
|
{badges.map((badge) => (
|
|
<div className="stack horizontal md items-center" key={badge.id}>
|
|
<Badge badge={badge} isAnimated size={32} />
|
|
<span>{badge.displayName}</span>
|
|
<SendouButton
|
|
className="ml-auto"
|
|
onPress={() => handleBadgeDelete(badge.id)}
|
|
icon={<TrashIcon />}
|
|
variant="minimal-destructive"
|
|
aria-label="Remove badge"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AvatarImageInput({
|
|
avatarImg,
|
|
setAvatarImg,
|
|
}: {
|
|
avatarImg: File | null;
|
|
setAvatarImg: (img: File | null) => void;
|
|
}) {
|
|
const baseEvent = useBaseEvent();
|
|
const [showPrevious, setShowPrevious] = React.useState(true);
|
|
|
|
if (
|
|
baseEvent?.avatarImgId &&
|
|
baseEvent?.tournament?.ctx.logoUrl &&
|
|
showPrevious
|
|
) {
|
|
const logoImgUrl = userSubmittedImage(baseEvent.tournament.ctx.logoUrl);
|
|
|
|
return (
|
|
<div className="stack horizontal md flex-wrap">
|
|
<input type="hidden" name="avatarImgId" value={baseEvent.avatarImgId} />
|
|
<div className="stack md items-center">
|
|
<img
|
|
src={logoImgUrl}
|
|
alt=""
|
|
className="calendar-new__avatar-preview"
|
|
/>
|
|
<SendouButton
|
|
variant="outlined"
|
|
size="small"
|
|
onPress={() => setShowPrevious(false)}
|
|
>
|
|
Edit logo
|
|
</SendouButton>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const hasPreviousAvatar = Boolean(baseEvent?.avatarImgId);
|
|
|
|
return (
|
|
<div>
|
|
<Label htmlFor="avatarImage">Logo</Label>
|
|
<input
|
|
id="avatarImage"
|
|
className="plain"
|
|
type="file"
|
|
name="img"
|
|
accept="image/png, image/jpeg, image/jpg, image/webp"
|
|
onChange={(e) => {
|
|
const uploadedFile = e.target.files?.[0];
|
|
if (!uploadedFile) {
|
|
setAvatarImg(null);
|
|
return;
|
|
}
|
|
|
|
new Compressor(uploadedFile, {
|
|
width: CALENDAR_EVENT.AVATAR_SIZE,
|
|
height: CALENDAR_EVENT.AVATAR_SIZE,
|
|
maxHeight: CALENDAR_EVENT.AVATAR_SIZE,
|
|
maxWidth: CALENDAR_EVENT.AVATAR_SIZE,
|
|
resize: "cover",
|
|
success(result) {
|
|
invariant(result instanceof Blob);
|
|
const file = new File([result], "img.webp", {
|
|
type: "image/webp",
|
|
});
|
|
|
|
setAvatarImg(file);
|
|
},
|
|
error(err) {
|
|
logger.error(err.message);
|
|
},
|
|
});
|
|
}}
|
|
/>
|
|
{avatarImg && (
|
|
<div className="mt-4 stack horizontal md flex-wrap">
|
|
<img
|
|
src={URL.createObjectURL(avatarImg)}
|
|
alt=""
|
|
className="calendar-new__avatar-preview"
|
|
/>
|
|
</div>
|
|
)}
|
|
<FormMessage type="info">
|
|
Note that for non-patrons there is a validation process before avatar is
|
|
shown.
|
|
</FormMessage>
|
|
{hasPreviousAvatar && (
|
|
<SendouButton
|
|
variant="minimal-destructive"
|
|
size="small"
|
|
onPress={() => setShowPrevious(true)}
|
|
className="mt-2"
|
|
>
|
|
Cancel changing avatar image
|
|
</SendouButton>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RankedToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [isRanked, setIsRanked] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.isRanked ?? true,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Ranked
|
|
</label>
|
|
<SendouSwitch
|
|
name="isRanked"
|
|
id={id}
|
|
size="small"
|
|
isSelected={isRanked}
|
|
onChange={setIsRanked}
|
|
/>
|
|
<FormMessage type="info">
|
|
Ranked tournaments affect SP. Tournaments that don't have open
|
|
registration (skill capped) or "gimmick tournaments" must
|
|
always be hosted as unranked. Any tournament hosted during off-season is
|
|
always unranked no matter what is chosen here.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EnableNoScreenToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [enableNoScreen, setEnableNoScreen] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.enableNoScreenToggle ?? true,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Splattercolor Screen toggle
|
|
</label>
|
|
<SendouSwitch
|
|
name="enableNoScreenToggle"
|
|
id={id}
|
|
size="small"
|
|
isSelected={enableNoScreen}
|
|
onChange={setEnableNoScreen}
|
|
/>
|
|
<FormMessage type="info">
|
|
When registering ask teams if they want to play without Splattercolor
|
|
Screen.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EnableSubsToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [enableSubs, setEnableSubs] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.enableSubs ?? true,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Subs tab
|
|
</label>
|
|
<SendouSwitch
|
|
name="enableSubs"
|
|
id={id}
|
|
size="small"
|
|
isSelected={enableSubs}
|
|
onChange={setEnableSubs}
|
|
/>
|
|
<FormMessage type="info">
|
|
Allow users to sign up as "subs" in addition to the normal event
|
|
sign-up.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AutonomousSubsToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [autonomousSubs, setAutonomousSubs] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.autonomousSubs ?? true,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Autonomous subs
|
|
</label>
|
|
<SendouSwitch
|
|
name="autonomousSubs"
|
|
id={id}
|
|
size="small"
|
|
isSelected={autonomousSubs}
|
|
onChange={setAutonomousSubs}
|
|
/>
|
|
<FormMessage type="info">
|
|
If enabled teams can add subs on their own while the tournament is in
|
|
progress. When disabled needs to be done by the TO's.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RequireIGNToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [requireIGNs, setRequireIGNs] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.requireInGameNames ?? false,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Require in-game names
|
|
</label>
|
|
<SendouSwitch
|
|
name="requireInGameNames"
|
|
id={id}
|
|
size="small"
|
|
isSelected={requireIGNs}
|
|
onChange={setRequireIGNs}
|
|
/>
|
|
<FormMessage type="info">
|
|
If enabled players can't join the tournament without an in-game
|
|
name (e.g. Sendou#1234). Players can't change the IGNs after the
|
|
registration closes.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InvitationalToggle({
|
|
isInvitational,
|
|
setIsInvitational,
|
|
}: {
|
|
isInvitational: boolean;
|
|
setIsInvitational: (value: boolean) => void;
|
|
}) {
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Invitational
|
|
</label>
|
|
<SendouSwitch
|
|
name="isInvitational"
|
|
id={id}
|
|
size="small"
|
|
isSelected={isInvitational}
|
|
onChange={setIsInvitational}
|
|
/>
|
|
<FormMessage type="info">
|
|
No open registration or subs list. All teams must be added by the
|
|
organizer.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StrictDeadlinesToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [strictDeadlines, setStrictDeadlines] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.deadlines === "STRICT",
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Strict deadlines
|
|
</label>
|
|
<SendouSwitch
|
|
name="strictDeadline"
|
|
id={id}
|
|
size="small"
|
|
isSelected={strictDeadlines}
|
|
onChange={setStrictDeadlines}
|
|
/>
|
|
<FormMessage type="info">
|
|
Strict deadlines has 5 minutes less for the target time of each round
|
|
(25min Bo3, 35min Bo5 compared to 30min Bo3, 40min Bo5 normal).
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TestToggle() {
|
|
const baseEvent = useBaseEvent();
|
|
const [isTest, setIsTest] = React.useState(
|
|
baseEvent?.tournament?.ctx.settings.isTest ?? false,
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Test
|
|
</label>
|
|
<SendouSwitch
|
|
name="isTest"
|
|
id={id}
|
|
size="small"
|
|
isSelected={isTest}
|
|
onChange={setIsTest}
|
|
/>
|
|
<FormMessage type="info">
|
|
Test tournaments don't appear on the calendar, don't send notifications
|
|
to players, and won't show up in players' profiles or results
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RegClosesAtSelect() {
|
|
const baseEvent = useBaseEvent();
|
|
const [regClosesAt, setRegClosesAt] = React.useState<RegClosesAtOption>(
|
|
baseEvent?.tournament?.ctx.settings.regClosesAt
|
|
? datesToRegClosesAt({
|
|
startTime: databaseTimestampToDate(
|
|
baseEvent.tournament.ctx.startTime,
|
|
),
|
|
regClosesAt: databaseTimestampToDate(
|
|
baseEvent.tournament.ctx.settings.regClosesAt,
|
|
),
|
|
})
|
|
: "0",
|
|
);
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Registration closes at
|
|
</label>
|
|
<select
|
|
name="regClosesAt"
|
|
value={regClosesAt}
|
|
onChange={(e) => setRegClosesAt(e.target.value as RegClosesAtOption)}
|
|
className="w-max"
|
|
>
|
|
{REG_CLOSES_AT_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{regClosesAtToDisplayName(option)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<FormMessage type="info">
|
|
All times relative to the reported tournament start time e.g. "30
|
|
minutes" means "30 minutes before event start time".
|
|
After registration closes only TO's can make changes to team
|
|
rosters.
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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",
|
|
};
|
|
function TournamentMapPickingStyleSelect() {
|
|
const { t } = useTranslation(["common"]);
|
|
const id = React.useId();
|
|
const { eventToEdit } = useLoaderData<typeof loader>();
|
|
const baseEvent = useBaseEvent();
|
|
const [mode, setMode] = React.useState<"ALL" | "TO" | RankedModeShort>(
|
|
baseEvent?.mapPickingStyle
|
|
? mapPickingStyleToShort[baseEvent.mapPickingStyle]
|
|
: "ALL",
|
|
);
|
|
const [mapPool, setMapPool] = React.useState<MapPool>(
|
|
baseEvent?.mapPool ? new MapPool(baseEvent.mapPool) : MapPool.EMPTY,
|
|
);
|
|
|
|
// can't change toToolsMode in editing
|
|
// and also can't change tiebreaker maps in editing
|
|
// because otherwise some team's picks that they already made
|
|
// might start to overlap with tiebreaker maps
|
|
if (eventToEdit && mode !== "TO") {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="stack md w-full items-start">
|
|
<Divider>Tournament maps</Divider>
|
|
<div>
|
|
<label htmlFor={id}>Map picking style</label>
|
|
<select
|
|
onChange={(e) => setMode(e.target.value as RankedModeShort)}
|
|
name="toToolsMode"
|
|
defaultValue={mode}
|
|
id={id}
|
|
disabled={Boolean(eventToEdit)}
|
|
>
|
|
<option value="ALL">Prepicked by teams - All modes</option>
|
|
<option value="SZ">Prepicked by teams - SZ only</option>
|
|
<option value="TC">Prepicked by teams - TC only</option>
|
|
<option value="RM">Prepicked by teams - RM only</option>
|
|
<option value="CB">Prepicked by teams - CB only</option>
|
|
<option value="TO">Picked by TO</option>
|
|
</select>
|
|
</div>
|
|
{mode === "ALL" ? <CounterPickMapPoolSection /> : null}
|
|
{mode === "TO" ? (
|
|
<>
|
|
<input type="hidden" name="pool" value={mapPool.serialized} />
|
|
|
|
<MapPoolSelector
|
|
className="w-full"
|
|
mapPool={mapPool}
|
|
title={t("common:maps.mapPool")}
|
|
handleMapPoolChange={setMapPool}
|
|
allowBulkEdit
|
|
/>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MapPoolSection() {
|
|
const { t } = useTranslation(["game-misc", "common"]);
|
|
|
|
const baseEvent = useBaseEvent();
|
|
const [mapPool, setMapPool] = React.useState<MapPool>(
|
|
baseEvent?.mapPool ? new MapPool(baseEvent.mapPool) : MapPool.EMPTY,
|
|
);
|
|
const [includeMapPool, setIncludeMapPool] = React.useState(
|
|
Boolean(baseEvent?.mapPool),
|
|
);
|
|
|
|
const id = React.useId();
|
|
|
|
return includeMapPool ? (
|
|
<>
|
|
<input type="hidden" name="pool" value={mapPool.serialized} />
|
|
|
|
<MapPoolSelector
|
|
className="w-full"
|
|
mapPool={mapPool}
|
|
title={t("common:maps.mapPool")}
|
|
handleRemoval={() => setIncludeMapPool(false)}
|
|
handleMapPoolChange={setMapPool}
|
|
allowBulkEdit
|
|
/>
|
|
</>
|
|
) : (
|
|
<div>
|
|
<label htmlFor={id}>{t("common:maps.mapPool")}</label>
|
|
<AddButton onAdd={() => setIncludeMapPool(true)} id={id} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CounterPickMapPoolSection() {
|
|
const { t } = useTranslation(["common"]);
|
|
const baseEvent = useBaseEvent();
|
|
const [mapPool, setMapPool] = React.useState<MapPool>(
|
|
baseEvent?.tieBreakerMapPool
|
|
? new MapPool(baseEvent.tieBreakerMapPool)
|
|
: MapPool.EMPTY,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<RequiredHiddenInput
|
|
value={mapPool.serialized}
|
|
name="pool"
|
|
isValid={validateTiebreakerMapPool(mapPool) === "VALID"}
|
|
/>
|
|
|
|
<MapPoolSelector
|
|
className="w-full"
|
|
mapPool={mapPool}
|
|
handleMapPoolChange={setMapPool}
|
|
title={t("common:maps.tieBreakerMapPool")}
|
|
modesToInclude={["SZ", "TC", "RM", "CB"]}
|
|
hideBanned
|
|
info={
|
|
<div>
|
|
<MapPoolValidationStatusMessage
|
|
status={validateTiebreakerMapPool(mapPool)}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type CounterPickValidationStatus =
|
|
| "PICKING"
|
|
| "VALID"
|
|
| "NOT_ONE_MAP_PER_MODE"
|
|
| "MAP_REPEATED"
|
|
| "MODE_REPEATED";
|
|
|
|
function validateTiebreakerMapPool(
|
|
mapPool: MapPool,
|
|
): CounterPickValidationStatus {
|
|
if (mapPool.stages.length !== new Set(mapPool.stages).size) {
|
|
return "MAP_REPEATED";
|
|
}
|
|
if (
|
|
mapPool.parsed.SZ.length > 1 ||
|
|
mapPool.parsed.TC.length > 1 ||
|
|
mapPool.parsed.RM.length > 1 ||
|
|
mapPool.parsed.CB.length > 1
|
|
) {
|
|
return "MODE_REPEATED";
|
|
}
|
|
if (
|
|
mapPool.parsed.SZ.length < 1 ||
|
|
mapPool.parsed.TC.length < 1 ||
|
|
mapPool.parsed.RM.length < 1 ||
|
|
mapPool.parsed.CB.length < 1
|
|
) {
|
|
return "PICKING";
|
|
}
|
|
|
|
return "VALID";
|
|
}
|
|
|
|
function MapPoolValidationStatusMessage({
|
|
status,
|
|
}: {
|
|
status: CounterPickValidationStatus;
|
|
}) {
|
|
const { t } = useTranslation(["common"]);
|
|
|
|
const alertVariation: AlertVariation =
|
|
status === "VALID" ? "SUCCESS" : status === "PICKING" ? "INFO" : "WARNING";
|
|
|
|
return (
|
|
<div>
|
|
<Alert alertClassName="w-max" variation={alertVariation} tiny>
|
|
{t(`common:maps.validation.${status}`)}
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberCountSelect() {
|
|
const baseEvent = useBaseEvent();
|
|
const id = React.useId();
|
|
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="w-max">
|
|
Players count
|
|
</label>
|
|
<select
|
|
name="minMembersPerTeam"
|
|
defaultValue={
|
|
baseEvent?.tournament?.ctx.settings.minMembersPerTeam ?? 4
|
|
}
|
|
className="w-max"
|
|
>
|
|
{[4, 3, 2, 1].map((count) => (
|
|
<option key={count} value={count}>
|
|
{`${count}v${count}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
);
|
|
}
|