diff --git a/AGENTS.md b/AGENTS.md index 2d51b778e..98a1fd5a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,7 @@ - normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file) - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command - typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc. +- non-exported functions typically do not need JSDoc or at least it can be kept short ## Commands @@ -78,6 +79,7 @@ - before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json) - add only English translation and use `pnpm run i18n:sync` to initialize other jsons with empty string ready for translators - when using namespace e.g. `const { t } = useTranslation("settings"]);` it needs to be defined in the `handle` for that route e.g. `export const handle: SendouRouteHandle = { i18n: ["settings"], ... }`. Certain namespaces are always included and you don't have to worry about those: "common", "forms", "game-misc", "weapons", "front", "friends" +- if changing translation key names make sure to port over any already translated values for non-english languages if the english language is unchanged ## Commits diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index b6c367208..9ddaece0d 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -22,32 +22,46 @@ export function FormWithConfirm({ submitButtonTestId = "submit-button", submitButtonVariant = "destructive", fetcher: _fetcher, + isOpen, + onOpenChange, }: { fields?: ( | [name: string, value: string | number] | readonly [name: string, value: string | number] )[]; - children: React.ReactElement; + children?: React.ReactElement; dialogHeading: string; submitButtonText?: string; action?: string; submitButtonTestId?: string; submitButtonVariant?: SendouButtonProps["variant"]; fetcher?: FetcherWithComponents; + /** Controls the dialog open state. When provided, no child trigger is needed. */ + isOpen?: boolean; + onOpenChange?: (isOpen: boolean) => void; }) { const componentsFetcher = useFetcher(); const fetcher = _fetcher ?? componentsFetcher; const isHydrated = useHydrated(); const { t } = useTranslation(["common"]); - const [dialogOpen, setDialogOpen] = React.useState(false); + const [internalOpen, setInternalOpen] = React.useState(false); const formRef = React.useRef(null); const id = React.useId(); - const openDialog = React.useCallback(() => setDialogOpen(true), []); - const closeDialog = React.useCallback(() => setDialogOpen(false), []); + const isControlled = isOpen !== undefined; + const dialogOpen = isControlled ? isOpen : internalOpen; - invariant(React.isValidElement(children)); + const openDialog = React.useCallback(() => { + onOpenChange?.(true); + setInternalOpen(true); + }, [onOpenChange]); + const closeDialog = React.useCallback(() => { + onOpenChange?.(false); + setInternalOpen(false); + }, [onOpenChange]); + + invariant(!children || React.isValidElement(children)); React.useEffect(() => { if (fetcher.state === "loading") { @@ -93,10 +107,12 @@ export function FormWithConfirm({ - {React.cloneElement(children, { - onPress: openDialog, - type: "button", - })} + {children + ? React.cloneElement(children, { + onPress: openDialog, + type: "button", + }) + : null} ); } diff --git a/app/components/Main.module.css b/app/components/Main.module.css index 30c6f3782..37c50305c 100644 --- a/app/components/Main.module.css +++ b/app/components/Main.module.css @@ -3,6 +3,13 @@ padding: var(--layout-main-padding); margin-bottom: var(--s-32); min-height: calc(100dvh - var(--layout-nav-height)); + + /* Fill the whole content area (sidebars already excluded by the flex + layout) while staying a query container, so a descendant can break out + of the page max-width and size against the full width via cqw units. */ + &[data-main-breakout] { + max-width: none; + } } .normal { diff --git a/app/components/Main.tsx b/app/components/Main.tsx index e90048964..91eeb3eba 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -8,6 +8,7 @@ export const Main = ({ classNameOverwrite, halfWidth, bigger, + breakoutContainer, style, }: { children: React.ReactNode; @@ -15,6 +16,7 @@ export const Main = ({ classNameOverwrite?: string; halfWidth?: boolean; bigger?: boolean; + breakoutContainer?: boolean; style?: React.CSSProperties; }) => { return ( @@ -34,6 +36,7 @@ export const Main = ({ className, ) } + data-main-breakout={breakoutContainer || undefined} style={style} > {children} diff --git a/app/components/SortableTableHeader.module.css b/app/components/SortableTableHeader.module.css new file mode 100644 index 000000000..9cadb766b --- /dev/null +++ b/app/components/SortableTableHeader.module.css @@ -0,0 +1,26 @@ +.sortHeader { + display: inline-flex; + align-items: center; + gap: var(--s-1); + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-transform: inherit; +} + +.sortIcon { + width: 0.85rem; + height: 0.85rem; +} + +.sortIconInactive { + opacity: 0.4; + + .sortHeader:hover &, + .sortHeader:focus-visible & { + opacity: 0.85; + } +} diff --git a/app/components/SortableTableHeader.tsx b/app/components/SortableTableHeader.tsx new file mode 100644 index 000000000..d2dc5f016 --- /dev/null +++ b/app/components/SortableTableHeader.tsx @@ -0,0 +1,57 @@ +import clsx from "clsx"; +import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react"; +import styles from "./SortableTableHeader.module.css"; + +export type SortDirection = "asc" | "desc"; + +export type SortState = { + key: Key; + dir: SortDirection; +} | null; + +export function SortableTableHeader({ + label, + sortKey, + sort, + onChange, +}: { + label: string; + sortKey: Key; + sort: SortState; + onChange: (next: SortState) => void; +}) { + const active = sort?.key === sortKey; + + return ( + + + + ); +} + +function nextSortState( + current: SortState, + key: Key, +): SortState { + if (current?.key !== key) return { key, dir: "asc" }; + if (current.dir === "asc") return { key, dir: "desc" }; + return null; +} diff --git a/app/components/TimePopover.tsx b/app/components/TimePopover.tsx index 4e2b7d855..7dd3cfc28 100644 --- a/app/components/TimePopover.tsx +++ b/app/components/TimePopover.tsx @@ -11,7 +11,7 @@ import { LocaleTime } from "./LocaleTime"; import styles from "./TimePopover.module.css"; export default function TimePopover({ - time, + date, options = { minute: "numeric", hour: "numeric", @@ -22,7 +22,7 @@ export default function TimePopover({ className, footerText, }: { - time: Date; + date: Date; options?: Intl.DateTimeFormatOptions; underline?: boolean; className?: string; @@ -61,7 +61,7 @@ export default function TimePopover({ setOpen(true); }} > - +
copyToClipboard(``)} + onPress={() => copyToClipboard(``)} icon={copySuccess ? : } > {t("common:actions.copyTimestampForDiscord")} diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 1744c06bd..1b709c178 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -69,6 +69,7 @@ export interface LinkButtonProps { children?: React.ReactNode; onClick?: React.MouseEventHandler; testId?: string; + "aria-label"?: string; } export function LinkButton({ @@ -84,6 +85,7 @@ export function LinkButton({ children, onClick, testId, + "aria-label": ariaLabel, }: LinkButtonProps) { if (isExternal) { return ( @@ -94,6 +96,7 @@ export function LinkButton({ rel="noreferrer" onClick={onClick} data-testid={testId} + aria-label={ariaLabel} > {icon && React.cloneElement(icon, { @@ -112,6 +115,7 @@ export function LinkButton({ prefetch={prefetch} preventScrollReset={preventScrollReset} onClick={onClick} + aria-label={ariaLabel} > {icon && React.cloneElement(icon, { diff --git a/app/components/elements/TournamentSearch.module.css b/app/components/elements/SearchSelect.module.css similarity index 97% rename from app/components/elements/TournamentSearch.module.css rename to app/components/elements/SearchSelect.module.css index b6cbe4a25..04d82792d 100644 --- a/app/components/elements/TournamentSearch.module.css +++ b/app/components/elements/SearchSelect.module.css @@ -18,6 +18,7 @@ .itemTextsContainer { line-height: 1.1; + font-size: var(--font-sm); & span { max-width: 175px; diff --git a/app/components/elements/SearchSelect.tsx b/app/components/elements/SearchSelect.tsx new file mode 100644 index 000000000..b152ceee8 --- /dev/null +++ b/app/components/elements/SearchSelect.tsx @@ -0,0 +1,160 @@ +import clsx from "clsx"; +import { ChevronsUpDown, Search, X } from "lucide-react"; +import type * as React from "react"; +import { + Autocomplete, + Button, + Input, + type Key, + ListBox, + ListBoxItem, + Popover, + SearchField, + Select, + type SelectProps, + SelectValue, +} from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { SendouBottomTexts } from "~/components/elements/BottomTexts"; +import { SendouLabel } from "~/components/elements/Label"; +import searchSelectStyles from "./SearchSelect.module.css"; +import selectStyles from "./Select.module.css"; +import type { EntitySearch } from "./useEntitySearch"; + +const PLACEHOLDER_TEXTS = { + teamSearch: { + placeholder: "common:forms.teamSearch.placeholder", + noResults: "common:forms.teamSearch.noResults", + }, + tournamentSearch: { + placeholder: "common:forms.tournamentSearch.placeholder", + noResults: "common:forms.tournamentSearch.noResults", + }, + userSearch: { + placeholder: "common:forms.userSearch.placeholder", + noResults: "common:forms.userSearch.noResults", + }, +} as const; + +interface SearchSelectProps< + TItem extends { id: number; name: string }, + T extends object, +> extends Omit, "children" | "onChange"> { + name?: string; + label?: string; + bottomText?: string; + errorText?: string; + ariaLabel: string; + inputTestId: string; + inputClassName?: string; + i18nKey: keyof typeof PLACEHOLDER_TEXTS; + search: EntitySearch; + buttonRef?: React.Ref; + renderItem: (item: TItem) => React.ReactElement; +} + +/** + * Presentational autocomplete select shared by the entity search components + * (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Wire up data fetching + * with `useEntitySearch` and pass its result as `search`. + */ +export function SearchSelect< + TItem extends { id: number; name: string }, + T extends object, +>({ + name, + label, + bottomText, + errorText, + ariaLabel, + inputTestId, + inputClassName, + i18nKey, + search, + buttonRef, + renderItem, + ...rest +}: SearchSelectProps) { + return ( + + + + + {(item) => + typeof item.id === "string" ? ( + + ) : ( + renderItem(item as TItem) + ) + } + + + + + ); +} + +function PlaceholderItem({ + id, + i18nKey, +}: { + id: "PLACEHOLDER" | "NO_RESULTS"; + i18nKey: keyof typeof PLACEHOLDER_TEXTS; +}) { + const { t } = useTranslation(["common"]); + + // for some reason the `renderEmptyState` on ListBox is not working + // so doing this as a workaround + return ( + + {id === "PLACEHOLDER" + ? t(PLACEHOLDER_TEXTS[i18nKey].placeholder) + : t(PLACEHOLDER_TEXTS[i18nKey].noResults)} + + ); +} diff --git a/app/components/elements/Tabs.module.css b/app/components/elements/Tabs.module.css index 152450cfd..f0b9b802b 100644 --- a/app/components/elements/Tabs.module.css +++ b/app/components/elements/Tabs.module.css @@ -67,15 +67,22 @@ } } -.padded { - & .tabPanel { - padding-block-start: var(--s-4); - } +.root { + --tabs-gap: var(--s-6); +} + +.root:not(.vertical) { + display: flex; + flex-direction: column; +} + +.padded:not(.vertical) { + gap: var(--tabs-gap); } .disappearing { - &:has(.tabList > div:only-child).padded .tabPanel { - padding-top: 0; + &:has(.tabList > div:only-child) { + gap: 0; } & .tabList:has(> div:only-child) { @@ -98,7 +105,7 @@ .vertical { display: grid; grid-template-columns: max-content 1fr; - gap: var(--s-8); + gap: var(--tabs-gap); align-items: start; & .tabListContainer { @@ -133,15 +140,17 @@ border-inline-end: 2px solid transparent; text-align: start; flex: none; + width: 100%; padding: var(--s-2) var(--s-3); padding-inline-end: var(--s-6); } + & .tabNumber { + margin-inline-start: auto; + padding-inline-start: var(--s-3); + } + & .tabPanel { min-width: 0; } - - &.padded .tabPanel { - padding-block-start: 0; - } } diff --git a/app/components/elements/Tabs.tsx b/app/components/elements/Tabs.tsx index 4fd63ccf6..5304e5ef4 100644 --- a/app/components/elements/Tabs.tsx +++ b/app/components/elements/Tabs.tsx @@ -75,7 +75,7 @@ export function SendouTabs({ ["results"][number], + { type: "team" } +>; + +interface TeamSearchProps + extends Omit, "children" | "onChange"> { + name?: string; + label?: string; + bottomText?: string; + errorText?: string; + /** Team to preselect and display on mount (e.g. when editing a linked team). */ + initialTeam?: { id: number; name: string; avatarUrl?: string | null }; + onChange?: (team: TeamSearchResult | null) => void; +} + +export const TeamSearch = React.forwardRef(function TeamSearch< + T extends object, +>( + { + name, + label, + bottomText, + errorText, + initialTeam, + onChange, + ...rest + }: TeamSearchProps, + ref?: React.Ref, +) { + const search = useEntitySearch({ + buildUrl: (query) => `/search?q=${query}&type=teams&limit=6`, + parseResults: parseTeamResults, + initialItem: initialTeam as TeamSearchResult | undefined, + initialSelectedId: initialTeam?.id, + onChange, + }); + + return ( + } + /> + ); +}); + +function parseTeamResults( + data: unknown, + query: string, +): TeamSearchResult[] | null { + const searchData = data as SearchLoaderData; + if (!searchData || searchData.query !== query) return null; + return searchData.results.filter( + (result): result is TeamSearchResult => result.type === "team", + ); +} + +function TeamItem({ item }: { item: TeamSearchResult }) { + return ( + + clsx(searchSelectStyles.item, { + [selectStyles.itemFocused]: isFocused, + [selectStyles.itemSelected]: isSelected, + }) + } + data-testid="team-search-item" + > + {item.avatarUrl ? ( + + ) : ( +
+ )} +
+ {item.name} +
+ + ); +} diff --git a/app/components/elements/TournamentSearch.tsx b/app/components/elements/TournamentSearch.tsx index 4e115f93a..ee810fbff 100644 --- a/app/components/elements/TournamentSearch.tsx +++ b/app/components/elements/TournamentSearch.tsx @@ -1,30 +1,13 @@ import clsx from "clsx"; import { sub } from "date-fns"; -import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; -import { - Autocomplete, - Button, - Input, - type Key, - ListBox, - ListBoxItem, - Popover, - SearchField, - Select, - type SelectProps, - SelectValue, -} from "react-aria-components"; -import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; -import { useDebounce } from "react-use"; -import { SendouBottomTexts } from "~/components/elements/BottomTexts"; -import { SendouLabel } from "~/components/elements/Label"; +import { ListBoxItem, type SelectProps } from "react-aria-components"; import type { TournamentSearchLoaderData } from "~/features/tournament/routes/to.search"; import { LocaleTime } from "../LocaleTime"; - +import { SearchSelect } from "./SearchSelect"; +import searchSelectStyles from "./SearchSelect.module.css"; import selectStyles from "./Select.module.css"; -import tournamentSearchStyles from "./TournamentSearch.module.css"; +import { useEntitySearch } from "./useEntitySearch"; type TournamentSearchItem = NonNullable< Extract @@ -37,6 +20,12 @@ interface TournamentSearchProps bottomText?: string; errorText?: string; initialTournamentId?: number; + /** + * Restrict results to tournaments that have already started (finished/past) + * instead of the default recent + upcoming window. Useful e.g. for importing + * data from a previous tournament. + */ + pastOnly?: boolean; onChange?: (tournament: TournamentSearchItem | null) => void; } @@ -49,137 +38,65 @@ export const TournamentSearch = React.forwardRef(function TournamentSearch< bottomText, errorText, initialTournamentId, + pastOnly, onChange, ...rest }: TournamentSearchProps, ref?: React.Ref, ) { - const [selectedKey, setSelectedKey] = React.useState( - initialTournamentId ?? null, - ); - const list = useTournamentSearch(setSelectedKey); - - const onSelectionChange = (tournamentId: number) => { - setSelectedKey(tournamentId); - const tournament = list.items.find( - (tournament) => - typeof tournament.id === "number" && tournament.id === tournamentId, - ); - if (tournament && typeof tournament.id === "number") { - onChange?.(tournament as TournamentSearchItem); - } - }; - - // clear if selected user is not in the new filtered items - React.useEffect(() => { - if ( - selectedKey && - selectedKey !== initialTournamentId && - !list.items.some( - (tournament) => - typeof tournament.id === "number" && tournament.id === selectedKey, - ) - ) { - setSelectedKey(null); - onChange?.(null); - } - }, [list.items, selectedKey, onChange, initialTournamentId]); + const search = useEntitySearch({ + buildUrl: (query) => + pastOnly + ? `/to/search?q=${query}&limit=6&maxStartTime=${new Date().toISOString()}` + : `/to/search?q=${query}&limit=6&minStartTime=${sub(new Date(), { days: 7 }).toISOString()}`, + parseResults: parseTournamentResults, + initialSelectedId: initialTournamentId, + onChange, + }); return ( - - - - tournament !== undefined)} - className={selectStyles.listBox} - > - {(item) => } - - - - + name={name} + label={label} + bottomText={bottomText} + errorText={errorText} + ariaLabel="Tournament search" + inputTestId="tournament-search-input" + i18nKey="tournamentSearch" + search={search} + buttonRef={ref} + renderItem={(item) => } + /> ); }); -function TournamentItem({ - item, -}: { - item: - | TournamentSearchItem - | { - id: "NO_RESULTS"; - } - | { - id: "PLACEHOLDER"; - }; -}) { - const { t } = useTranslation(["common"]); - - if (typeof item.id === "string") { - return ( - - {item.id === "PLACEHOLDER" - ? t("common:forms.tournamentSearch.placeholder") - : t("common:forms.tournamentSearch.noResults")} - - ); +function parseTournamentResults( + data: unknown, + query: string, +): TournamentSearchItem[] | null { + const searchData = data as TournamentSearchLoaderData; + if (!searchData || Array.isArray(searchData) || searchData.query !== query) { + return null; } + return searchData.tournaments; +} +function TournamentItem({ item }: { item: TournamentSearchItem }) { return ( - clsx(tournamentSearchStyles.item, { + clsx(searchSelectStyles.item, { [selectStyles.itemFocused]: isFocused, [selectStyles.itemSelected]: isSelected, }) } data-testid="tournament-search-item" > - -
+ +
{item.name}
); } - -function useTournamentSearch( - setSelectedKey: (tournamentId: number | null) => void, -) { - const [filterText, setFilterText] = React.useState(""); - - const queryFetcher = useFetcher(); - - useDebounce( - () => { - if (!filterText) return; - queryFetcher.load( - `/to/search?q=${filterText}&limit=6&minStartTime=${sub(new Date(), { days: 7 }).toISOString()}`, - ); - setSelectedKey(null); - }, - 500, - [filterText], - ); - - const items = () => { - if ( - queryFetcher.data && - !Array.isArray(queryFetcher.data) && - queryFetcher.data.query === filterText - ) { - if (queryFetcher.data.tournaments.length === 0) { - return [{ id: "NO_RESULTS" }]; - } - return queryFetcher.data.tournaments; - } - - return [{ id: "PLACEHOLDER" }]; - }; - - return { - filterText, - setFilterText, - items: items(), - }; -} diff --git a/app/components/elements/UserSearch.module.css b/app/components/elements/UserSearch.module.css deleted file mode 100644 index 1192481d3..000000000 --- a/app/components/elements/UserSearch.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.item { - font-size: var(--font-sm); - font-weight: var(--weight-semi); - padding: var(--s-1-5); - border-radius: var(--radius-field); - height: 33px; - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - display: flex; - align-items: center; - gap: var(--s-2); -} - -.popover { - min-height: 250px; -} - -.itemTextsContainer { - line-height: 1.1; -} - -.selectValue { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - display: flex; - align-items: center; - gap: var(--s-2); -} - -button:disabled .selectValue { - color: var(--color-text-high); - font-style: italic; -} - -.placeholder { - font-size: var(--font-xs); - font-weight: var(--weight-semi); - color: var(--color-text-high); - text-align: center; - display: grid; - place-items: center; - height: 162px; - margin-block: var(--s-4); -} - -.itemAdditionalText { - font-size: var(--font-xs); - color: var(--color-text-high); -} - -button .itemAdditionalText { - display: none; -} diff --git a/app/components/elements/UserSearch.tsx b/app/components/elements/UserSearch.tsx index 6f95f4d4f..2dd97e338 100644 --- a/app/components/elements/UserSearch.tsx +++ b/app/components/elements/UserSearch.tsx @@ -1,29 +1,13 @@ import clsx from "clsx"; -import { ChevronsUpDown, Search, X } from "lucide-react"; import * as React from "react"; -import { - Autocomplete, - Button, - Input, - type Key, - ListBox, - ListBoxItem, - Popover, - SearchField, - Select, - type SelectProps, - SelectValue, -} from "react-aria-components"; -import { useTranslation } from "react-i18next"; +import { ListBoxItem, type SelectProps } from "react-aria-components"; import { useFetcher } from "react-router"; -import { useDebounce } from "react-use"; -import { SendouBottomTexts } from "~/components/elements/BottomTexts"; -import { SendouLabel } from "~/components/elements/Label"; import type { SearchLoaderData } from "~/features/search/routes/search"; import { Avatar } from "../Avatar"; - +import { SearchSelect } from "./SearchSelect"; +import searchSelectStyles from "./SearchSelect.module.css"; import selectStyles from "./Select.module.css"; -import userSearchStyles from "./UserSearch.module.css"; +import { useEntitySearch } from "./useEntitySearch"; type UserResult = Extract< NonNullable["results"][number], @@ -54,113 +38,63 @@ export const UserSearch = React.forwardRef(function UserSearch< }: UserSearchProps, ref?: React.Ref, ) { - const [selectedKey, setSelectedKey] = React.useState(initialUserId ?? null); - const { initialUser, items, ...list } = useUserSearch( - setSelectedKey, - initialUserId, - ); + const initialUser = useInitialUser(initialUserId); - const onSelectionChange = (userId: number) => { - setSelectedKey(userId); - onChange?.(items.find((user) => user.id === userId) as UserResult); - }; - - // clear if selected user is not in the new filtered items - const itemsJoined = items.map((user) => user.id).join(","); - React.useEffect(() => { - const ids = itemsJoined.split(",").map(Number); - - if ( - selectedKey && - selectedKey !== initialUserId && - !ids.includes(selectedKey) - ) { - setSelectedKey(null); - onChange?.(null); - } - }, [itemsJoined, selectedKey, onChange, initialUserId]); + const search = useEntitySearch({ + buildUrl: (query) => `/search?q=${query}&type=users&limit=6`, + parseResults: (data, query) => parseUserResults(data, query, initialUser), + initialItem: initialUser, + initialSelectedId: initialUserId, + onChange, + }); return ( - - - - user !== undefined)} - className={selectStyles.listBox} - > - {(item) => } - - - - + name={name} + label={label} + bottomText={bottomText} + errorText={errorText} + ariaLabel="User search" + inputTestId="user-search-input" + inputClassName="in-container" + i18nKey="userSearch" + search={search} + buttonRef={ref} + renderItem={(item) => } + /> ); }); -function UserItem({ - item, -}: { - item: - | UserResult - | { - id: "NO_RESULTS"; - } - | { - id: "PLACEHOLDER"; - }; -}) { - const { t } = useTranslation(["common"]); +function parseUserResults( + data: unknown, + query: string, + initialUser?: UserResult, +): UserResult[] | null { + const searchData = data as SearchLoaderData; + if (!searchData || searchData.query !== query) return null; + return searchData.results + .filter((result): result is UserResult => result.type === "user") + .filter((user) => user.id !== initialUser?.id); +} - // for some reason the `renderEmptyState` on ListBox is not working - // so doing this as a workaround - if (typeof item.id === "string") { - return ( - - {item.id === "PLACEHOLDER" - ? t("common:forms.userSearch.placeholder") - : t("common:forms.userSearch.noResults")} - - ); - } +/** Resolves the full user object for a preselected id so it can be displayed. */ +function useInitialUser(initialUserId?: number) { + const fetcher = useFetcher(); + React.useEffect(() => { + if (!initialUserId || fetcher.state !== "idle" || fetcher.data) { + return; + } + fetcher.load(`/search?q=${initialUserId}&type=users&limit=1`); + }, [initialUserId, fetcher]); + + return fetcher.data?.results.find( + (result): result is UserResult => result.type === "user", + ); +} + +function UserItem({ item }: { item: UserResult }) { const additionalText = () => { const plusServer = item.plusTier ? `+${item.plusTier}` : ""; const profileUrl = item.customUrl ? `/u/${item.customUrl}` : ""; @@ -185,7 +119,7 @@ function UserItem({ id={item.id} textValue={item.name} className={({ isFocused, isSelected }) => - clsx(userSearchStyles.item, { + clsx(searchSelectStyles.item, { [selectStyles.itemFocused]: isFocused, [selectStyles.itemSelected]: isSelected, }) @@ -193,10 +127,10 @@ function UserItem({ data-testid="user-search-item" > -
+
{item.name} {additionalText() ? ( -
+
{additionalText()}
) : null} @@ -204,66 +138,3 @@ function UserItem({ ); } - -function useUserSearch( - setSelectedKey: (userId: number | null) => void, - initialUserId?: number, -) { - const [filterText, setFilterText] = React.useState(""); - - const queryFetcher = useFetcher(); - const initialUserFetcher = useFetcher(); - - React.useEffect(() => { - if ( - !initialUserId || - initialUserFetcher.state !== "idle" || - initialUserFetcher.data - ) { - return; - } - initialUserFetcher.load(`/search?q=${initialUserId}&type=users&limit=1`); - }, [initialUserId, initialUserFetcher]); - - React.useEffect(() => { - if (initialUserId !== undefined) { - setSelectedKey(initialUserId); - } - }, [initialUserId, setSelectedKey]); - - useDebounce( - () => { - if (!filterText) return; - queryFetcher.load(`/search?q=${filterText}&type=users&limit=6`); - setSelectedKey(null); - }, - 500, - [filterText], - ); - - const initialUserResult = initialUserFetcher.data?.results.find( - (r): r is UserResult => r.type === "user", - ); - - const items = () => { - // data fetched for the query user has currently typed - if (queryFetcher.data && queryFetcher.data.query === filterText) { - const userResults = queryFetcher.data.results - .filter((r): r is UserResult => r.type === "user") - .filter((user) => user.id !== initialUserResult?.id); - if (userResults.length === 0) { - return [{ id: "NO_RESULTS" as const }]; - } - return userResults; - } - - return [{ id: "PLACEHOLDER" as const }]; - }; - - return { - filterText, - setFilterText, - items: items(), - initialUser: initialUserResult, - }; -} diff --git a/app/components/elements/useEntitySearch.ts b/app/components/elements/useEntitySearch.ts new file mode 100644 index 000000000..c69ef79b8 --- /dev/null +++ b/app/components/elements/useEntitySearch.ts @@ -0,0 +1,125 @@ +import * as React from "react"; +import { useFetcher } from "react-router"; +import { useDebounce } from "react-use"; + +/** Sentinel items rendered in place of real results while loading or when empty. */ +export type EntitySearchPlaceholder = + | { id: "PLACEHOLDER" } + | { id: "NO_RESULTS" }; + +export type EntitySearchItem = TItem | EntitySearchPlaceholder; + +interface UseEntitySearchArgs { + /** Builds the loader URL queried (debounced) as the user types. */ + buildUrl: (query: string) => string; + /** + * Turns raw loader data into result items. Return `null` when the data does + * not (yet) correspond to the current query so a placeholder is shown. + */ + parseResults: (data: unknown, query: string) => TItem[] | null; + /** Already resolved item to pin to the top of the list (e.g. when editing). */ + initialItem?: TItem; + /** Id to preselect on mount even before its item is resolved. */ + initialSelectedId?: number; + onChange?: (item: TItem | null) => void; +} + +export interface EntitySearch { + filterText: string; + setFilterText: (text: string) => void; + items: EntitySearchItem[]; + selectedKey: number | null; + onSelectionChange: (key: number) => void; +} + +/** + * Shared state + data fetching for the autocomplete search selects + * (e.g. `UserSearch`, `TeamSearch`, `TournamentSearch`). Pair with the + * presentational `SearchSelect` component, passing the returned value as its + * `search` prop. + */ +export function useEntitySearch({ + buildUrl, + parseResults, + initialItem, + initialSelectedId, + onChange, +}: UseEntitySearchArgs): EntitySearch { + const [filterText, setFilterText] = React.useState(""); + const [selectedKey, setSelectedKey] = React.useState( + initialSelectedId ?? null, + ); + + const queryFetcher = useFetcher(); + + useDebounce( + () => { + if (!filterText) return; + queryFetcher.load(buildUrl(filterText)); + setSelectedKey(null); + }, + 500, + [filterText], + ); + + React.useEffect(() => { + if (typeof initialSelectedId === "number") { + setSelectedKey(initialSelectedId); + } + }, [initialSelectedId]); + + const items = withInitialItem( + toEntitySearchItems(parseResults(queryFetcher.data, filterText)), + initialItem, + ); + + const realItems = items.filter( + (item): item is TItem => typeof item.id === "number", + ); + + // clear the selection when its item is no longer among the results + const realItemIdsKey = realItems.map((item) => item.id).join(","); + React.useEffect(() => { + if (!realItemIdsKey) return; + const ids = realItemIdsKey.split(",").map(Number); + if ( + selectedKey && + selectedKey !== initialSelectedId && + !ids.includes(selectedKey) + ) { + setSelectedKey(null); + onChange?.(null); + } + }, [realItemIdsKey, selectedKey, onChange, initialSelectedId]); + + const onSelectionChange = (key: number) => { + setSelectedKey(key); + const item = realItems.find((item) => item.id === key); + if (item) { + onChange?.(item); + } + }; + + return { filterText, setFilterText, items, selectedKey, onSelectionChange }; +} + +function toEntitySearchItems( + parsed: TItem[] | null, +): EntitySearchItem[] { + if (parsed === null) return [{ id: "PLACEHOLDER" }]; + if (parsed.length === 0) return [{ id: "NO_RESULTS" }]; + return parsed; +} + +function withInitialItem( + items: EntitySearchItem[], + initialItem?: TItem, +): EntitySearchItem[] { + if (!initialItem) return items; + return [ + initialItem, + ...items.filter( + (item) => typeof item.id !== "number" || item.id !== initialItem.id, + ), + ]; +} diff --git a/app/components/match-page/MatchBannerScheduledTime.tsx b/app/components/match-page/MatchBannerScheduledTime.tsx index 7df2e56c1..4f9f6e7fe 100644 --- a/app/components/match-page/MatchBannerScheduledTime.tsx +++ b/app/components/match-page/MatchBannerScheduledTime.tsx @@ -9,7 +9,7 @@ export function MatchBannerScheduledTime({ }: MatchBannerScheduledTimeProps) { return ( {tabs.map((tab) => ( diff --git a/app/db/tables.ts b/app/db/tables.ts index 999d55166..2cf6d6bb4 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -818,10 +818,13 @@ export interface TournamentLFGLike { createdAt: Generated; } +export const TOURNAMENT_STAFF_ROLES = ["ORGANIZER", "STREAMER"] as const; +type TournamentStaffRole = (typeof TOURNAMENT_STAFF_ROLES)[number]; + export interface TournamentStaff { tournamentId: number; userId: number; - role: "ORGANIZER" | "STREAMER"; + role: TournamentStaffRole; } export interface TournamentTeam { @@ -844,6 +847,8 @@ export interface TournamentTeam { chatCode: Generated; /** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */ abDivision: number | null; + /** The team's {@link TournamentTeamHistory} row, created lazily on its first audited event. */ + tournamentTeamHistoryId: number | null; } export interface TournamentTeamCheckIn { @@ -866,6 +871,48 @@ export interface TournamentTeamMember { isLooking: Generated; } +/** Stable shadow of a tournament team's identity that survives the team's hard-deletion, so the audit log can still resolve its name. */ +export interface TournamentTeamHistory { + /** Surrogate key. Audit log rows reference this so a reused `TournamentTeam.id` can never collide with an older team's history. */ + id: GeneratedAlways; + /** Mirrors the original `TournamentTeam.id` at creation time. Informational only; not a live or unique foreign key, so it is not cascade-deleted with the team and may repeat across teams that reused an id. */ + tournamentTeamId: number; + tournamentId: number; + name: string; +} + +export const TOURNAMENT_AUDIT_LOG_TYPES = [ + "MEMBER_ADDED", + "MEMBER_REMOVED", + "TEAM_REGISTERED", + "TEAM_UNREGISTERED", + "TEAM_CHECKED_IN", + "TEAM_CHECKED_OUT", + "TEAM_DROPPED_OUT", + "TEAM_DROP_OUT_UNDONE", + "UPDATE_IN_GAME_NAME", +] as const; + +export interface TournamentAuditLog { + id: GeneratedAlways; + tournamentId: number; + type: (typeof TOURNAMENT_AUDIT_LOG_TYPES)[number]; + /** The user who performed the action. */ + actorUserId: number; + /** The affected member, for member-level events. `null` for team-level events. */ + subjectUserId: number | null; + /** References {@link TournamentTeamHistory.id} so the team name stays resolvable after the team is hard-deleted. */ + tournamentTeamHistoryId: number | null; + metadata: JSONColumnTypeNullable; + createdAt: number; +} + +export interface TournamentAuditLogMetadata { + bracketIdx?: number; + /** The new in-game name, for `UPDATE_IN_GAME_NAME` events. */ + inGameName?: string; +} + export interface TournamentOrganization { id: GeneratedAlways; name: string; @@ -1459,6 +1506,8 @@ export interface DB { TournamentTeam: TournamentTeam; TournamentTeamCheckIn: TournamentTeamCheckIn; TournamentTeamMember: TournamentTeamMember; + TournamentTeamHistory: TournamentTeamHistory; + TournamentAuditLog: TournamentAuditLog; TournamentOrganization: TournamentOrganization; TournamentOrganizationMember: TournamentOrganizationMember; TournamentOrganizationBadge: TournamentOrganizationBadge; diff --git a/app/features/api-public/routes/tournament.$id.seeds.ts b/app/features/api-public/routes/tournament.$id.seeds.ts index 87b7a00eb..97d1bbac1 100644 --- a/app/features/api-public/routes/tournament.$id.seeds.ts +++ b/app/features/api-public/routes/tournament.$id.seeds.ts @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server"; +import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server"; import { parseBody, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { wrapActionForApi } from "../api-action-wrapper.server"; @@ -32,7 +32,7 @@ export const action = async (args: ActionFunctionArgs) => { }), }); - return wrapActionForApi(seedsAction, { + return wrapActionForApi(adminAction, { ...args, params: { id: String(tournamentId) }, request: internalRequest, diff --git a/app/features/api-public/routes/tournament.$id.starting-brackets.ts b/app/features/api-public/routes/tournament.$id.starting-brackets.ts index 6931e7236..3a9271d8c 100644 --- a/app/features/api-public/routes/tournament.$id.starting-brackets.ts +++ b/app/features/api-public/routes/tournament.$id.starting-brackets.ts @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { action as seedsAction } from "~/features/tournament/actions/to.$id.seeds.server"; +import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server"; import { parseBody, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { wrapActionForApi } from "../api-action-wrapper.server"; @@ -37,7 +37,7 @@ export const action = async (args: ActionFunctionArgs) => { }), }); - return wrapActionForApi(seedsAction, { + return wrapActionForApi(adminAction, { ...args, params: { id: String(tournamentId) }, request: internalRequest, diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index aa2eb6277..7a622313c 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -1,7 +1,21 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server"; -import { parseBody, parseParams } from "~/utils/remix.server"; +import { requireUser } from "~/features/auth/core/user.server"; +import { userIsBanned } from "~/features/ban/core/banned.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { + errorToastIfFalsy, + parseBody, + parseParams, +} from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { wrapActionForApi } from "../api-action-wrapper.server"; @@ -24,19 +38,81 @@ export const action = async (args: ActionFunctionArgs) => { schema: bodySchema, }); - const internalRequest = new Request(args.request.url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - _action: "ADD_MEMBER", - teamId, - userId, - }), - }); + return wrapActionForApi(async () => { + const user = requireUser(); + const tournament = await tournamentFromDB({ tournamentId, user }); + errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); - return wrapActionForApi(adminAction, { - ...args, - params: { id: String(tournamentId) }, - request: internalRequest, - }); + const team = tournament.teamById(teamId); + errorToastIfFalsy(team, "Invalid team id"); + + const previousTeam = tournament.teamMemberOfByUser({ id: userId }); + + errorToastIfFalsy( + !previousTeam?.id || previousTeam.id !== team.id, + "User is already in this team", + ); + + errorToastIfFalsy( + tournament.hasStarted || !previousTeam, + "User is already in a team", + ); + + errorToastIfFalsy( + !userIsBanned(userId), + "User trying to be added currently has an active ban from sendou.ink", + ); + + const addMemberUser = await UserRepository.findLeanById(userId); + errorToastIfFalsy(addMemberUser?.friendCode, "User has no friend code set"); + errorToastIfFalsy( + !tournament.ctx.settings.requireInGameNames || addMemberUser?.inGameName, + "User has no in-game name set", + ); + + await TournamentLFGRepository.leaveLfg({ + userId, + tournamentId, + }); + await TournamentTeamRepository.join({ + userId, + newTeamId: team.id, + previousTeamId: previousTeam?.id, + // this team is not checked in & tournament started, so we can simply delete it + whatToDoWithPreviousTeam: + previousTeam && + previousTeam.checkIns.length === 0 && + tournament.hasStarted + ? "DELETE" + : undefined, + }); + + ShowcaseTournaments.addToCached({ + tournamentId, + type: "participant", + userId, + }); + + if (!tournament.isTest && !tournament.isDraft) { + notify({ + userIds: [userId], + notification: { + type: "TO_ADDED_TO_TEAM", + pictureUrl: + tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl, + meta: { + adderUsername: user.username, + teamName: team.name, + tournamentId, + tournamentName: tournament.ctx.name, + tournamentTeamId: team.id, + }, + }, + }); + } + + clearTournamentDataCache(tournamentId); + + return null; + }, args); }; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index df162cbff..18d027a73 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -1,7 +1,17 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server"; -import { parseBody, parseParams } from "~/utils/remix.server"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { + errorToastIfFalsy, + parseBody, + parseParams, +} from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { wrapActionForApi } from "../api-action-wrapper.server"; @@ -24,19 +34,50 @@ export const action = async (args: ActionFunctionArgs) => { schema: bodySchema, }); - const internalRequest = new Request(args.request.url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - _action: "REMOVE_MEMBER", - teamId, - memberId: userId, - }), - }); + return wrapActionForApi(async () => { + const user = requireUser(); + const tournament = await tournamentFromDB({ tournamentId, user }); + errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); - return wrapActionForApi(adminAction, { - ...args, - params: { id: String(tournamentId) }, - request: internalRequest, - }); + const team = tournament.teamById(teamId); + errorToastIfFalsy(team, "Invalid team id"); + errorToastIfFalsy( + team.checkIns.length === 0 || + team.members.length > tournament.minMembersPerTeam, + "Can't remove last member from checked in team", + ); + errorToastIfFalsy( + team.members.find((m) => m.userId === userId)?.role !== "OWNER", + "Cannot remove team owner", + ); + errorToastIfFalsy( + !tournament.hasStarted || + !tournament + .participatedPlayersByTeamId(teamId) + .some((p) => p.userId === userId), + "Cannot remove player that has participated in the tournament", + ); + + if (team.activeRosterUserIds?.includes(userId)) { + await TournamentTeamRepository.setActiveRoster({ + teamId: team.id, + activeRosterUserIds: null, + }); + } + + await TournamentTeamRepository.leave({ + userId, + teamId: team.id, + }); + + ShowcaseTournaments.removeFromCached({ + tournamentId, + type: "participant", + userId, + }); + + clearTournamentDataCache(tournamentId); + + return null; + }, args); }; diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts index 99f4610e8..ca0a95424 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts @@ -1,8 +1,18 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; -import { action as adminAction } from "~/features/tournament/actions/to.$id.admin.server"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; import { IN_GAME_NAME_REGEXP } from "~/features/user-page/user-page-constants"; -import { parseBody, parseParams } from "~/utils/remix.server"; +import { + badRequestIfFalsy, + errorToastIfFalsy, + parseBody, + parseParams, +} from "~/utils/remix.server"; import { id } from "~/utils/zod"; import { wrapActionForApi } from "../api-action-wrapper.server"; @@ -26,24 +36,23 @@ export const action = async (args: ActionFunctionArgs) => { schema: bodySchema, }); - const hashIndex = inGameName.lastIndexOf("#"); - const inGameNameText = inGameName.slice(0, hashIndex); - const inGameNameDiscriminator = inGameName.slice(hashIndex + 1); + return wrapActionForApi(async () => { + const user = requireUser(); + const tournament = await tournamentFromDB({ tournamentId, user }); + errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); - const internalRequest = new Request(args.request.url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - _action: "UPDATE_IN_GAME_NAME", - memberId: userId, - inGameNameText, - inGameNameDiscriminator, - }), - }); + const teamMemberOf = badRequestIfFalsy( + tournament.teamMemberOfByUser({ id: userId }), + ); - return wrapActionForApi(adminAction, { - ...args, - params: { id: String(tournamentId) }, - request: internalRequest, - }); + await TournamentTeamRepository.updateMemberInGameName({ + userId, + inGameName, + tournamentTeamId: teamMemberOf.id, + }); + + clearTournamentDataCache(tournamentId); + + return null; + }, args); }; diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts index 2fb09c967..def725b17 100644 --- a/app/features/calendar/calendar-constants.ts +++ b/app/features/calendar/calendar-constants.ts @@ -57,8 +57,8 @@ export const tags = { export const CALENDAR_EVENT = { NAME_MIN_LENGTH: 2, NAME_MAX_LENGTH: 100, - DESCRIPTION_MAX_LENGTH: 3000, - RULES_MAX_LENGTH: 10_000, + DESCRIPTION_MAX_LENGTH: 6000, + RULES_MAX_LENGTH: 15_000, DISCORD_INVITE_CODE_MAX_LENGTH: 50, BRACKET_URL_MAX_LENGTH: 200, MAX_AMOUNT_OF_DATES: 5, diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index 2fd3dcc39..20e4baf50 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -4,6 +4,7 @@ import * as R from "remeda"; 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 * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import { requireRole } from "~/modules/permissions/guards.server"; @@ -28,7 +29,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { if (!event) return; - if (!event?.tournamentId) return { ...event, tournament: null }; + if (!event?.tournamentId) + return { ...event, tournament: null, rules: null }; return { ...event, @@ -36,6 +38,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { tournamentId: event.tournamentId, user, }), + rules: await TournamentRepository.findRulesById(event.tournamentId), }; }; diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 4addf7dd5..d0a8107bc 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -384,9 +384,7 @@ function OrganizationSelect() { function RulesTextarea({ supportsMarkdown }: { supportsMarkdown?: boolean }) { const baseEvent = useBaseEvent(); - const [value, setValue] = React.useState( - baseEvent?.tournament?.ctx.rules ?? "", - ); + const [value, setValue] = React.useState(baseEvent?.rules ?? ""); return (
diff --git a/app/features/components-showcase/form-examples-schema.ts b/app/features/components-showcase/form-examples-schema.ts index cfc67727d..dadbc6214 100644 --- a/app/features/components-showcase/form-examples-schema.ts +++ b/app/features/components-showcase/form-examples-schema.ts @@ -147,7 +147,7 @@ export const formFieldsShowcaseSchema = z.object({ label: "labels.vodWeapon", }), user: userSearchOptional({ - label: "labels.banUserPlayer", + label: "labels.player", }), // Image fields diff --git a/app/features/img-upload/upload-constants.ts b/app/features/img-upload/upload-constants.ts index f85c5bb46..07cd28ed9 100644 --- a/app/features/img-upload/upload-constants.ts +++ b/app/features/img-upload/upload-constants.ts @@ -1,17 +1,5 @@ -import type { ImageUploadType } from "./upload-types"; - export const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"]; export const MAX_UNVALIDATED_IMG_COUNT = 5; export const IMAGES_TO_VALIDATE_AT_ONCE = 5; - -export const IMAGE_TYPES = ["team-pfp", "team-banner"] as const; - -export const imgTypeToDimensions: Record< - ImageUploadType, - { width: number; height: number } -> = { - "team-pfp": { width: 400, height: 400 }, - "team-banner": { width: 1000, height: 500 }, -}; diff --git a/app/features/img-upload/upload-types.ts b/app/features/img-upload/upload-types.ts deleted file mode 100644 index 84b98c194..000000000 --- a/app/features/img-upload/upload-types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { IMAGE_TYPES } from "./upload-constants"; - -export type ImageUploadType = (typeof IMAGE_TYPES)[number]; diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index 30e27177d..0a8cc424f 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -1,7 +1,6 @@ import { add } from "date-fns"; import { type ActionFunctionArgs, redirect } from "react-router"; import type { z } from "zod"; -import type { Tables } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; import { userIsBanned } from "~/features/ban/core/banned.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -13,6 +12,7 @@ import { assertUnreachable } from "~/utils/types"; import { scrimsPage } from "~/utils/urls"; import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server"; import * as TeamRepository from "../../team/TeamRepository.server"; +import { NON_PLAYER_TEAM_ROLES } from "../../team/team-constants"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; import { LUTI_DIVS, SCRIM } from "../scrims-constants"; import { @@ -102,12 +102,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { return redirect(scrimsPage()); }; -const ROLES_TO_EXCLUDE: Tables["TeamMember"]["role"][] = [ - "CHEERLEADER", - "COACH", - "SUB", -]; - export const usersListForPost = async ({ from, authorId, @@ -126,7 +120,7 @@ export const usersListForPost = async ({ errorToastIfFalsy(team, "User is not a member of this team"); const filteredMembers = team.members.filter( - (member) => !ROLES_TO_EXCLUDE.includes(member.role), + (member) => !member.role || !NON_PLAYER_TEAM_ROLES.includes(member.role), ); // handle case when all users are from excluded roles diff --git a/app/features/scrims/components/ScrimCard.tsx b/app/features/scrims/components/ScrimCard.tsx index b829500be..945ec9fff 100644 --- a/app/features/scrims/components/ScrimCard.tsx +++ b/app/features/scrims/components/ScrimCard.tsx @@ -262,7 +262,7 @@ function ScrimStartTimeDisplay({ const timeDisplay = ( ({ type: "team" as const, + id: t.id, name: t.name, avatarUrl: t.avatarUrl, customUrl: t.customUrl, + members: t.members, })); } case "organizations": { diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index b79114cd5..ad7be9200 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -4,6 +4,7 @@ import { db } from "~/db/sql"; import type { CustomTheme, DB, Tables } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; import * as LFGRepository from "~/features/lfg/LFGRepository.server"; +import { NON_PLAYER_TEAM_ROLES } from "~/features/team/team-constants"; import { subsOfResult } from "~/features/team/team-utils"; import { databaseTimestampNow } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; @@ -50,11 +51,30 @@ export function searchByName({ .selectFrom("Team") .leftJoin("UserSubmittedImage", "UserSubmittedImage.id", "Team.avatarImgId") .select(({ eb }) => [ + "Team.id", "Team.customUrl", "Team.name", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "avatarUrl", ), + jsonArrayFrom( + eb + .selectFrom("TeamMemberWithSecondary") + .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") + .select(["User.id", "User.username"]) + .whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id") + .where((eb2) => + eb2.or([ + eb2("TeamMemberWithSecondary.role", "is", null), + eb2( + "TeamMemberWithSecondary.role", + "not in", + NON_PLAYER_TEAM_ROLES, + ), + ]), + ) + .orderBy("TeamMemberWithSecondary.isOwner", "desc"), + ).as("members"), ]) .where("Team.name", "like", `%${query}%`) .orderBy("Team.name", "asc") @@ -62,6 +82,14 @@ export function searchByName({ .execute(); } +export function findById(teamId: number) { + return db + .selectFrom("AllTeam") + .select(["AllTeam.id", "AllTeam.name"]) + .where("AllTeam.id", "=", teamId) + .executeTakeFirst(); +} + export function findAllMemberOfByUserId(userId: number) { return db .selectFrom("TeamMemberWithSecondary") diff --git a/app/features/team/team-constants.ts b/app/features/team/team-constants.ts index 24f496707..786d0f873 100644 --- a/app/features/team/team-constants.ts +++ b/app/features/team/team-constants.ts @@ -23,3 +23,7 @@ export const TEAM_MEMBER_ROLES = [ "COACH", "CHEERLEADER", ] as const; + +/** Roles that are not part of a team's active competitive lineup. Excluded when sourcing a roster (e.g. prefilling tournament registration or a scrim post). */ +export const NON_PLAYER_TEAM_ROLES: readonly (typeof TEAM_MEMBER_ROLES)[number][] = + ["CHEERLEADER", "COACH", "SUB"]; diff --git a/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts new file mode 100644 index 000000000..85ebbac65 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.brackets.server.ts @@ -0,0 +1,112 @@ +import type { ActionFunction } from "react-router"; +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { + errorToastIfFalsy, + parseParams, + parseRequestPayload, + successToast, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { idObject } from "../../../utils/zod"; +import { adminBracketsActionSchema } from "../tournament-admin-schemas.server"; +import { + requireTournamentAdmin, + requireTournamentOrganizer, +} from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + const data = await parseRequestPayload({ + request, + schema: adminBracketsActionSchema, + }); + + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + let message: string; + switch (data._action) { + case "RESET_BRACKET": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + + const bracketToResetIdx = tournament.brackets.findIndex( + (b) => b.id === data.stageId, + ); + const bracketToReset = tournament.brackets[bracketToResetIdx]; + errorToastIfFalsy(bracketToReset, "Invalid bracket id"); + errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started"); + + const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); + errorToastIfFalsy( + inProgressBrackets.every( + (b) => + !b.sources || + b.sources.every((s) => s.bracketIdx !== bracketToResetIdx), + ), + "Some bracket that sources teams from this bracket has started", + ); + + await TournamentRepository.resetBracket(data.stageId); + + message = "Bracket reset"; + break; + } + case "UPDATE_TOURNAMENT_PROGRESSION": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); + + errorToastIfFalsy( + Progression.changedBracketProgression( + tournament.ctx.settings.bracketProgression, + data.bracketProgression, + ).every( + (changedBracketIdx) => + tournament.bracketByIdx(changedBracketIdx)?.preview, + ), + "Can't change started brackets", + ); + + await TournamentRepository.updateProgression({ + tournamentId: tournament.ctx.id, + bracketProgression: data.bracketProgression, + }); + + message = "Tournament progression updated"; + break; + } + case "REOPEN_TOURNAMENT": { + requireTournamentAdmin(tournament, user); + errorToastIfFalsy( + DANGEROUS_CAN_ACCESS_DEV_CONTROLS, + "Only available in development", + ); + errorToastIfFalsy( + tournament.ctx.isFinalized, + "Tournament is not finalized", + ); + + await TournamentRepository.reopenTournament(tournamentId); + + message = "Tournament reopened"; + break; + } + default: { + assertUnreachable(data); + } + } + + clearTournamentDataCache(tournamentId); + + return successToast(message); +}; diff --git a/app/features/tournament-admin/actions/to.$id.admin.index.server.ts b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts new file mode 100644 index 000000000..4136fac76 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.index.server.ts @@ -0,0 +1,221 @@ +import type { ActionFunction } from "react-router"; +import * as R from "remeda"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { endDroppedTeamMatches } from "~/features/tournament/tournament-utils.server"; +import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils"; +import { tournamentMatchWebsocketRoom } from "~/features/tournament-match/tournament-match-utils"; +import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; +import { + errorToastIfFalsy, + parseParams, + parseRequestPayload, +} from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { idObject } from "../../../utils/zod"; +import { adminTeamsActionSchema } from "../tournament-admin-schemas.server"; +import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + const data = await parseRequestPayload({ + request, + schema: adminTeamsActionSchema, + }); + + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + switch (data._action) { + case "CHECK_IN": { + requireTournamentOrganizer(tournament, user); + const team = tournament.teamById(data.teamId); + errorToastIfFalsy(team, "Invalid team id"); + errorToastIfFalsy( + data.bracketIdx !== 0 || + tournament.checkInConditionsFulfilledByTeamId(team.id).isFulfilled, + `Can't check-in - ${tournament.checkInConditionsFulfilledByTeamId(team.id).reason}`, + ); + errorToastIfFalsy( + team.checkIns.length > 0 || data.bracketIdx === 0, + "Can't check-in to follow up bracket if not checked in for the event itself", + ); + + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + errorToastIfFalsy(bracket.preview, "Bracket has been started"); + + await TournamentTeamRepository.checkIn(data.teamId, { + // no sources = regular check in + bracketIdx: bracket.sources ? data.bracketIdx : undefined, + }); + + break; + } + case "CHECK_OUT": { + requireTournamentOrganizer(tournament, user); + const team = tournament.teamById(data.teamId); + errorToastIfFalsy(team, "Invalid team id"); + errorToastIfFalsy( + data.bracketIdx !== 0 || !tournament.hasStarted, + "Tournament has started", + ); + + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Invalid bracket idx"); + errorToastIfFalsy(bracket.preview, "Bracket has been started"); + + await TournamentTeamRepository.checkOut({ + tournamentTeamId: data.teamId, + // no sources = regular check in + bracketIdx: !bracket.sources ? null : data.bracketIdx, + }); + logger.info( + `Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`, + ); + + break; + } + case "DELETE_TEAM": { + requireTournamentOrganizer(tournament, user); + const team = tournament.teamById(data.teamId); + errorToastIfFalsy(team, "Invalid team id"); + errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + + await TournamentTeamRepository.del(team.id); + + for (const member of team.members) { + ShowcaseTournaments.removeFromCached({ + tournamentId, + type: "participant", + userId: member.userId, + }); + + ShowcaseTournaments.updateCachedTournamentTeamCount({ + tournamentId, + newTeamCount: tournament.ctx.teams.length - 1, + }); + } + + break; + } + case "DROP_TEAM_OUT": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(tournament.teamById(data.teamId), "Invalid team id"); + + const endedMatchIds = await dropTeamOut({ + tournament, + manager: getServerTournamentManager(), + teamId: data.teamId, + }); + + sendDroppedMatchChatMessages({ + tournamentId: tournament.ctx.id, + endedMatchIds, + authorUserId: user.id, + }); + + break; + } + case "UNDO_DROP_TEAM_OUT": { + requireTournamentOrganizer(tournament, user); + + await TournamentTeamRepository.undoDropOut(data.teamId); + + break; + } + default: { + assertUnreachable(data); + } + } + + clearTournamentDataCache(tournamentId); + + return null; +}; + +/** + * Drops a single team out: assigns a random active roster for teams with subs, + * ends their in-progress matches and marks the team dropped out. Returns the ids + * of matches that were ended so the caller can broadcast a single batch of chat + * messages. + */ +async function dropTeamOut({ + tournament, + manager, + teamId, +}: { + tournament: Awaited>; + manager: ReturnType; + teamId: number; +}) { + const droppingTeam = tournament.teamById(teamId); + invariant(droppingTeam, "Invalid team id"); + + // Set active roster only for teams with subs (can't infer which players played) + // Teams without subs have their roster trivially inferred in summarizer + const hasSubs = droppingTeam.members.length > tournament.minMembersPerTeam; + if (hasSubs && !droppingTeam.activeRosterUserIds) { + const randomRoster = R.sample( + droppingTeam.members.map((m) => m.userId), + tournament.minMembersPerTeam, + ); + await TournamentTeamRepository.setActiveRoster({ + teamId, + activeRosterUserIds: randomRoster, + }); + } + + const endedMatchIds = endDroppedTeamMatches({ + tournament, + manager, + droppedTeamId: teamId, + }); + + await TournamentTeamRepository.dropOut({ + tournamentTeamId: teamId, + previewBracketIdxs: tournament.brackets.flatMap((b, idx) => + b.preview ? idx : [], + ), + }); + + return endedMatchIds; +} + +function sendDroppedMatchChatMessages({ + tournamentId, + endedMatchIds, + authorUserId, +}: { + tournamentId: number; + endedMatchIds: number[]; + authorUserId: number; +}) { + if (endedMatchIds.length === 0) return; + + ChatSystemMessage.send([ + ...endedMatchIds.map((matchId) => ({ + room: tournamentMatchWebsocketRoom(matchId), + type: "TOURNAMENT_MATCH_UPDATED" as const, + revalidateOnly: true as const, + authorUserId, + })), + { + room: tournamentWebsocketRoom(tournamentId), + type: "TOURNAMENT_UPDATED" as const, + revalidateOnly: true as const, + authorUserId, + }, + ]); +} diff --git a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts new file mode 100644 index 000000000..c16afac01 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts @@ -0,0 +1,143 @@ +import { type ActionFunction, redirect } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; +import invariant from "~/utils/invariant"; +import { parseParams } from "~/utils/remix.server"; +import { tournamentAdminPage } from "~/utils/urls"; +import { idObject } from "~/utils/zod"; +import { adminRegistrationFormSchemaServer } from "../tournament-admin-registration-schemas.server"; +import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ params, schema: idObject }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + requireTournamentOrganizer(tournament, user); + + const result = await parseFormDataWithImages({ + request, + schema: adminRegistrationFormSchemaServer({ tournament }), + }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; + + const submittedMembers = data.members; + const ownerUserId = Number(data.ownerId); + + const linkedTeamId = data.linkedTeam ? data.teamId : null; + const name = linkedTeamId + ? (await TeamRepository.findById(linkedTeamId))!.name + : data.pickUpName!; + + // linked teams source their logo from the sendou.ink team, so any pickup avatar is cleared + const avatarImgId = linkedTeamId ? null : data.logo; + + let team: NonNullable> | undefined; + if (typeof data.tournamentTeamId === "number") { + team = tournament.teamById(data.tournamentTeamId); + } + + const currentMemberIds = team?.members.map((member) => member.userId) ?? []; + const submittedMemberIds = submittedMembers.map((member) => member.userId); + const membersToAdd = submittedMemberIds.filter( + (memberId) => !currentMemberIds.includes(memberId), + ); + const membersToRemove = currentMemberIds.filter( + (memberId) => !submittedMemberIds.includes(memberId), + ); + + const ownerChange = (() => { + if (!team) return null; + const currentOwner = team.members.find((m) => m.role === "OWNER"); + invariant(currentOwner, "Team has no owner"); + return currentOwner.userId !== ownerUserId + ? { oldOwnerId: currentOwner.userId, newOwnerId: ownerUserId } + : null; + })(); + + const inGameNameUpdates = submittedMembers.flatMap((member) => { + if (!member.inGameName) return []; + const current = team?.members.find((m) => m.userId === member.userId); + if (current && current.inGameName === member.inGameName) return []; + return [{ userId: member.userId, inGameName: member.inGameName }]; + }); + + await TournamentTeamRepository.upsertRegistration({ + tournamentTeamId: team?.id, + tournamentId, + name, + teamId: linkedTeamId, + avatarImgId, + ownerUserId, + ownerChange, + membersToAdd, + membersToRemove, + inGameNameUpdates, + }); + + for (const addId of membersToAdd) { + await TournamentLFGRepository.leaveLfg({ + userId: addId, + tournamentId, + }); + ShowcaseTournaments.addToCached({ + tournamentId, + type: "participant", + userId: addId, + }); + } + for (const removeId of membersToRemove) { + ShowcaseTournaments.removeFromCached({ + tournamentId, + type: "participant", + userId: removeId, + }); + } + + if ( + team && + membersToAdd.length > 0 && + !tournament.isTest && + !tournament.isDraft + ) { + notify({ + userIds: membersToAdd, + notification: { + type: "TO_ADDED_TO_TEAM", + pictureUrl: + tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl, + meta: { + adderUsername: user.username, + teamName: name, + tournamentId, + tournamentName: tournament.ctx.name, + tournamentTeamId: team.id, + }, + }, + }); + } + + if (!team) { + ShowcaseTournaments.updateCachedTournamentTeamCount({ + tournamentId, + newTeamCount: tournament.ctx.teams.length + 1, + }); + } + + clearTournamentDataCache(tournamentId); + + return redirect(tournamentAdminPage(tournamentId)); +}; diff --git a/app/features/tournament/actions/to.$id.seeds.server.ts b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts similarity index 66% rename from app/features/tournament/actions/to.$id.seeds.server.ts rename to app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts index df4539e07..f8ca73732 100644 --- a/app/features/tournament/actions/to.$id.seeds.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.seeds.server.ts @@ -1,5 +1,7 @@ import type { ActionFunction } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { clearTournamentDataCache, tournamentFromDB, @@ -10,28 +12,30 @@ import { parseRequestPayload, successToast, } from "~/utils/remix.server"; -import { idObject } from "~/utils/zod"; -import * as TournamentRepository from "../TournamentRepository.server"; -import * as TournamentTeamRepository from "../TournamentTeamRepository.server"; -import { seedsActionSchema } from "../tournament-schemas.server"; +import { assertUnreachable } from "~/utils/types"; +import { idObject } from "../../../utils/zod"; +import { adminSeedsActionSchema } from "../tournament-admin-schemas.server"; +import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); const data = await parseRequestPayload({ request, - schema: seedsActionSchema, + schema: adminSeedsActionSchema, }); - const user = requireUser(); + const { id: tournamentId } = parseParams({ params, schema: idObject, }); const tournament = await tournamentFromDB({ tournamentId, user }); - errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer"); - errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); - + let message: string; switch (data._action) { case "UPDATE_SEEDS": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + const teamsWithMembers = tournament.ctx.teams .filter((t) => data.seeds.includes(t.id)) .map((team) => ({ @@ -47,10 +51,14 @@ export const action: ActionFunction = async ({ request, params }) => { teamIds: data.seeds, teamsWithMembers, }); - clearTournamentDataCache(tournamentId); - return successToast("Seeds saved successfully"); + + message = "Seeds saved successfully"; + break; } case "UPDATE_STARTING_BRACKETS": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + const validBracketIdxs = tournament.ctx.settings.bracketProgression.flatMap( (bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []), @@ -66,9 +74,14 @@ export const action: ActionFunction = async ({ request, params }) => { await TournamentTeamRepository.updateStartingBrackets( data.startingBrackets, ); + + message = "Starting brackets updated"; break; } case "UPDATE_AB_DIVISIONS": { + requireTournamentOrganizer(tournament, user); + errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); + errorToastIfFalsy( tournament.ctx.settings.bracketProgression.some( (bracket) => !bracket.sources && bracket.settings?.hasAbDivisions, @@ -83,11 +96,16 @@ export const action: ActionFunction = async ({ request, params }) => { ); await TournamentTeamRepository.updateAbDivisions(data.abDivisions); + + message = "A/B divisions updated"; break; } + default: { + assertUnreachable(data); + } } clearTournamentDataCache(tournamentId); - return null; + return successToast(message); }; diff --git a/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts b/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts new file mode 100644 index 000000000..2142b8d2f --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.staff.server.ts @@ -0,0 +1,72 @@ +import type { ActionFunction } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { parseFormData } from "~/form/parse.server"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "../../../utils/zod"; +import { adminStaffFormSchemaServer } from "../tournament-admin-schemas.server"; +import { requireTournamentAdmin } from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + requireTournamentAdmin(tournament, user); + + const result = await parseFormData({ + request, + schema: adminStaffFormSchemaServer({ tournament }), + }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const submittedStaff = result.data.staff; + + const currentOrganizerIds = tournament.ctx.staff + .filter((staffer) => staffer.role === "ORGANIZER") + .map((staffer) => staffer.id); + const submittedOrganizerIds = submittedStaff + .filter((staffer) => staffer.role === "ORGANIZER") + .map((staffer) => staffer.userId); + + await TournamentRepository.setStaff({ + tournamentId, + staff: submittedStaff.map((staffer) => ({ + userId: staffer.userId, + role: staffer.role, + })), + }); + + for (const userId of submittedOrganizerIds.filter( + (id) => !currentOrganizerIds.includes(id), + )) { + ShowcaseTournaments.addToCached({ + tournamentId, + type: "organizer", + userId, + }); + } + for (const userId of currentOrganizerIds.filter( + (id) => !submittedOrganizerIds.includes(id), + )) { + ShowcaseTournaments.removeFromCached({ + tournamentId, + type: "organizer", + userId, + }); + } + + clearTournamentDataCache(tournamentId); + + return null; +}; diff --git a/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts b/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts new file mode 100644 index 000000000..a38af6dd1 --- /dev/null +++ b/app/features/tournament-admin/actions/to.$id.admin.stream.server.ts @@ -0,0 +1,41 @@ +import type { ActionFunction } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { + clearTournamentDataCache, + tournamentFromDB, +} from "~/features/tournament-bracket/core/Tournament.server"; +import { parseFormData } from "~/form/parse.server"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; +import { adminStreamFormSchema } from "../tournament-admin-staff-schemas"; +import { requireTournamentOrganizer } from "../tournament-admin-utils.server"; + +export const action: ActionFunction = async ({ request, params }) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + const tournament = await tournamentFromDB({ tournamentId, user }); + + requireTournamentOrganizer(tournament, user); + + const result = await parseFormData({ + request, + schema: adminStreamFormSchema, + }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + + await TournamentRepository.updateCastTwitchAccounts({ + tournamentId: tournament.ctx.id, + castTwitchAccounts: result.data.castTwitchAccounts, + }); + + clearTournamentDataCache(tournamentId); + + return null; +}; diff --git a/app/features/tournament-admin/components/ExportDialog.module.css b/app/features/tournament-admin/components/ExportDialog.module.css new file mode 100644 index 000000000..92e910874 --- /dev/null +++ b/app/features/tournament-admin/components/ExportDialog.module.css @@ -0,0 +1,11 @@ +.fieldGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--s-2); +} + +.fieldLabel { + display: flex; + align-items: center; + gap: var(--s-2); +} diff --git a/app/features/tournament-admin/components/ExportDialog.tsx b/app/features/tournament-admin/components/ExportDialog.tsx new file mode 100644 index 000000000..c4be6f9d2 --- /dev/null +++ b/app/features/tournament-admin/components/ExportDialog.tsx @@ -0,0 +1,418 @@ +import * as React from "react"; +import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import * as CSV from "~/modules/csv"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { teamPage, userPage } from "~/utils/urls"; +import styles from "./ExportDialog.module.css"; + +const BASE_URL = "https://sendou.ink"; + +const EXPORT_FORMATS = ["list", "csv"] as const; +const EXPORT_STATUSES = ["all", "checkedIn", "notCheckedIn"] as const; +const EXPORT_SORTS = ["name", "seed", "registration"] as const; + +type ExportFormat = (typeof EXPORT_FORMATS)[number]; +type ExportStatus = (typeof EXPORT_STATUSES)[number]; +type ExportSort = (typeof EXPORT_SORTS)[number]; + +const FORMAT_LABELS: Record = { + list: "List", + csv: "CSV", +}; +const STATUS_LABELS: Record = { + all: "All teams", + checkedIn: "Checked in only", + notCheckedIn: "Not checked in", +}; +const SORT_LABELS: Record = { + name: "Name", + seed: "Seed", + registration: "Registration time", +}; + +const TEAM_FIELDS = [ + "teamName", + "seed", + "registeredAt", + "checkInStatus", + "teamPageUrl", +] as const; +const MEMBER_FIELDS = [ + "memberUsername", + "memberInGameName", + "memberDiscord", + "memberProfileUrl", +] as const; +type ExportField = + | (typeof TEAM_FIELDS)[number] + | (typeof MEMBER_FIELDS)[number]; + +const FIELD_LABELS: Record = { + teamName: "Team", + seed: "Seed", + registeredAt: "Registration time", + checkInStatus: "Check-in", + teamPageUrl: "Team page URL", + memberUsername: "Username", + memberInGameName: "In-game name", + memberDiscord: "Discord mention", + memberProfileUrl: "Profile URL", +}; + +const DEFAULT_FIELDS: ExportField[] = [ + "teamName", + "seed", + "checkInStatus", + "memberUsername", +]; + +export function ExportDialog({ close }: { close: () => void }) { + const tournament = useTournament(); + + const [format, setFormat] = React.useState("list"); + const [status, setStatus] = React.useState("all"); + const [bracketIdx, setBracketIdx] = React.useState(null); + const [sort, setSort] = React.useState("seed"); + const [fields, setFields] = React.useState>( + new Set(DEFAULT_FIELDS), + ); + + const toggleField = (field: ExportField) => + setFields((prev) => { + const next = new Set(prev); + if (next.has(field)) { + next.delete(field); + } else { + next.add(field); + } + return next; + }); + + const onDownload = () => { + const teams = scopedAndSortedTeams({ + teams: tournament.ctx.teams, + status, + sort, + bracketIdx, + bracketParticipantIds: + bracketIdx !== null + ? new Set( + tournament.brackets[bracketIdx]?.participantTournamentTeamIds ?? + [], + ) + : null, + }); + const content = buildContent({ + teams, + format, + fields, + bracketIdx, + checkedInLabel: "Checked in", + notCheckedInLabel: "Not checked in", + }); + handleDownload({ + filename: `participants.${format === "csv" ? "csv" : "txt"}`, + content, + format, + }); + close(); + }; + + return ( + +
+ ({ + value, + label: FORMAT_LABELS[value], + }))} + /> + +
+
Fields
+
+ {[...TEAM_FIELDS, ...MEMBER_FIELDS].map((field) => ( + + ))} +
+
+ + {tournament.brackets.length > 1 ? ( + + setBracketIdx(value === "all" ? null : Number(value)) + } + options={[ + { value: "all", label: "All brackets" }, + ...tournament.brackets.map((bracket, idx) => ({ + value: String(idx), + label: bracket.name || `#${idx}`, + })), + ]} + /> + ) : null} + + ({ + value, + label: STATUS_LABELS[value], + }))} + /> + + ({ + value, + label: SORT_LABELS[value], + }))} + /> + + + Download + +
+
+ ); +} + +function RadioRow({ + label, + value, + onChange, + options, +}: { + label: string; + value: T; + onChange: (value: T) => void; + options: ReadonlyArray<{ value: T; label: string }>; +}) { + const groupName = React.useId(); + + return ( +
+
{label}
+ + {options.map((option) => ( + onChange(value as T)} + > + {option.label} + + ))} + +
+ ); +} + +function hasActiveCheckIn(team: TournamentDataTeam, bracketIdx: number | null) { + const relevant = team.checkIns.filter( + (checkIn) => checkIn.bracketIdx === bracketIdx, + ); + return ( + relevant.some((checkIn) => !checkIn.isCheckOut) && + !relevant.some((checkIn) => checkIn.isCheckOut) + ); +} + +function scopedAndSortedTeams({ + teams, + status, + sort, + bracketIdx, + bracketParticipantIds, +}: { + teams: TournamentDataTeam[]; + status: ExportStatus; + sort: ExportSort; + bracketIdx: number | null; + bracketParticipantIds: Set | null; +}) { + const filtered = teams.filter((team) => { + if (bracketParticipantIds && !bracketParticipantIds.has(team.id)) { + return false; + } + switch (status) { + case "checkedIn": + return hasActiveCheckIn(team, bracketIdx); + case "notCheckedIn": + return !hasActiveCheckIn(team, bracketIdx); + default: + return true; + } + }); + + return [...filtered].sort((a, b) => { + switch (sort) { + case "name": + return a.name.localeCompare(b.name); + case "registration": + return a.createdAt - b.createdAt; + default: { + const aSeed = a.seed ?? Number.POSITIVE_INFINITY; + const bSeed = b.seed ?? Number.POSITIVE_INFINITY; + if (aSeed !== bSeed) return aSeed - bSeed; + return a.createdAt - b.createdAt; + } + } + }); +} + +function teamFieldValue( + team: TournamentDataTeam, + field: (typeof TEAM_FIELDS)[number], + opts: { + checkedInLabel: string; + notCheckedInLabel: string; + bracketIdx: number | null; + }, +) { + switch (field) { + case "teamName": + return team.name; + case "seed": + return team.seed != null ? String(team.seed) : ""; + case "registeredAt": + return databaseTimestampToDate(team.createdAt).toISOString(); + case "checkInStatus": + return hasActiveCheckIn(team, opts.bracketIdx) + ? opts.checkedInLabel + : opts.notCheckedInLabel; + case "teamPageUrl": + return team.team?.customUrl + ? `${BASE_URL}${teamPage(team.team.customUrl)}` + : ""; + } +} + +function memberFieldValue( + member: TournamentDataTeam["members"][number], + field: (typeof MEMBER_FIELDS)[number], +) { + switch (field) { + case "memberUsername": + return member.username; + case "memberInGameName": + return member.inGameName ?? ""; + case "memberDiscord": + return member.discordId ? `<@${member.discordId}>` : ""; + case "memberProfileUrl": + return `${BASE_URL}${userPage(member)}`; + } +} + +function buildContent({ + teams, + format, + fields, + bracketIdx, + checkedInLabel, + notCheckedInLabel, +}: { + teams: TournamentDataTeam[]; + format: ExportFormat; + fields: Set; + bracketIdx: number | null; + checkedInLabel: string; + notCheckedInLabel: string; +}) { + const teamFields = TEAM_FIELDS.filter((field) => fields.has(field)); + const memberFields = MEMBER_FIELDS.filter((field) => fields.has(field)); + const labelOpts = { checkedInLabel, notCheckedInLabel, bracketIdx }; + + if (format === "csv") { + const maxRoster = Math.max(0, ...teams.map((team) => team.members.length)); + + const header = [ + ...teamFields.map((field) => FIELD_LABELS[field]), + ...Array.from({ length: maxRoster }).flatMap((_, i) => + memberFields.map((field) => `Player ${i + 1} ${FIELD_LABELS[field]}`), + ), + ]; + + const rows = teams.map((team) => { + const teamValues = teamFields.map((field) => + teamFieldValue(team, field, labelOpts), + ); + const memberValues = Array.from({ length: maxRoster }).flatMap((_, i) => { + const member = team.members[i]; + return memberFields.map((field) => + member ? memberFieldValue(member, field) : "", + ); + }); + return [...teamValues, ...memberValues]; + }); + + return CSV.serialize([header, ...rows]); + } + + // list: members grouped under each team (omitted when no member fields chosen, + // so a team-name-only export is just a plain list of names) + const hasMemberFields = memberFields.length > 0; + + const entries = teams.map((team) => { + const teamLine = teamFields + .map((field) => teamFieldValue(team, field, labelOpts)) + .filter(Boolean) + .join(" - "); + if (!hasMemberFields) return teamLine; + const memberLines = team.members.map( + (member) => + ` ${memberFields + .map((field) => memberFieldValue(member, field)) + .filter(Boolean) + .join(" - ")}`, + ); + return [teamLine, ...memberLines].join("\n"); + }); + + return entries.join(hasMemberFields ? "\n\n" : "\n"); +} + +function handleDownload({ + content, + filename, + format, +}: { + content: string; + filename: string; + format: ExportFormat; +}) { + const isCsv = format === "csv"; + const element = document.createElement("a"); + const file = new Blob(isCsv ? [CSV.BOM, content] : [content], { + type: isCsv ? "text/csv;charset=utf-8" : "text/plain;charset=utf-8", + }); + element.href = URL.createObjectURL(file); + element.download = filename; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); +} diff --git a/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts new file mode 100644 index 000000000..75c3e468f --- /dev/null +++ b/app/features/tournament-admin/loaders/to.$id.admin.audit.server.ts @@ -0,0 +1,65 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { z } from "zod"; +import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as TournamentAuditLogRepository from "~/features/tournament/TournamentAuditLogRepository.server"; +import { AUDIT_LOG_PAGE_SIZE } from "~/features/tournament/TournamentAuditLogRepository.server"; +import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; +import { + forbidden, + parseParams, + parseSearchParams, + redirectIfPageOutOfBounds, +} from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; + +const auditSearchParamsSchema = z.object({ + page: z.coerce.number().int().min(1).catch(1), + auditType: z.enum(TOURNAMENT_AUDIT_LOG_TYPES).optional().catch(undefined), + auditTeam: z.coerce.number().int().optional().catch(undefined), +}); + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const user = requireUser(); + + const { id: tournamentId } = parseParams({ params, schema: idObject }); + + const tournament = await tournamentFromDBCached({ tournamentId, user }); + if (!tournament.isOrganizer(user)) forbidden(); + + const { page, auditType, auditTeam } = parseSearchParams({ + request, + schema: auditSearchParamsSchema, + }); + + const [events, totalCount, teams] = await Promise.all([ + TournamentAuditLogRepository.findByTournamentId({ + tournamentId, + type: auditType, + tournamentTeamHistoryId: auditTeam, + limit: AUDIT_LOG_PAGE_SIZE, + offset: (page - 1) * AUDIT_LOG_PAGE_SIZE, + }), + TournamentAuditLogRepository.countByTournamentId({ + tournamentId, + type: auditType, + tournamentTeamHistoryId: auditTeam, + }), + TournamentAuditLogRepository.findTeamsByTournamentId(tournamentId), + ]); + + const pagesCount = Math.max(1, Math.ceil(totalCount / AUDIT_LOG_PAGE_SIZE)); + + redirectIfPageOutOfBounds({ request, page, pagesCount }); + + return { + auditLog: { + events, + teams, + currentPage: page, + pagesCount, + }, + }; +}; + +export type TournamentAdminAuditLoader = typeof loader; diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.module.css b/app/features/tournament-admin/routes/to.$id.admin._index.module.css new file mode 100644 index 000000000..a9d18cde7 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin._index.module.css @@ -0,0 +1,96 @@ +.toolbar { + display: flex; + flex-direction: column; + gap: var(--s-2); + row-gap: var(--s-4); +} + +.toolbarActions { + display: flex; + gap: var(--s-2); + justify-content: flex-end; +} + +.searchInput { + width: 100%; +} + +@container (min-width: 32rem) { + .toolbar { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + .toolbarActions { + order: 1; + } + + .searchInput { + flex: 1; + max-width: 22rem; + } +} + +.teamName { + font-weight: var(--weight-semi); + white-space: nowrap; + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; +} + +.droppedOut { + opacity: 0.6; +} + +.checkInTrigger { + display: inline-flex; + align-items: center; + gap: var(--s-0-5); + padding: var(--s-1); +} + +.checkInMark { + width: 1rem; + height: 1rem; + color: var(--color-success); +} + +.checkInCross { + width: 1rem; + height: 1rem; + color: var(--color-error); +} + +.checkInScopes { + display: flex; + flex-direction: column; + gap: var(--s-2); + margin: 0; + padding: 0; + list-style: none; +} + +.checkInScope { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: var(--s-2); + font-size: var(--font-xs); +} + +.checkInScopeLabel { + font-weight: var(--weight-semi); +} + +.checkInScopeStatus { + color: var(--color-text-high); + font-weight: var(--weight-body); +} + +.noResults { + text-align: center; + color: var(--color-text-lighter); + padding-block: var(--s-6); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx new file mode 100644 index 000000000..addf81d77 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -0,0 +1,546 @@ +import clsx from "clsx"; +import { + Check, + Download, + LogIn, + LogOut, + MoreHorizontal, + Pencil, + Plus, + RotateCcw, + Search, + Trash2, + X, +} from "lucide-react"; +import * as React from "react"; +import { useFetcher } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; +import { SendouPopover } from "~/components/elements/Popover"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { Input } from "~/components/Input"; +import { + SortableTableHeader, + type SortState, +} from "~/components/SortableTableHeader"; +import { Table } from "~/components/Table"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { + teamPage, + tournamentAdminRegistrationEditPage, + tournamentAdminRegistrationPage, +} from "~/utils/urls"; +import { queryToUserIdentifier } from "~/utils/users"; +import { ExportDialog } from "../components/ExportDialog"; + +import styles from "./to.$id.admin._index.module.css"; + +export { action } from "../actions/to.$id.admin.index.server"; + +type SortKey = "name" | "checkIn"; + +export default function TournamentAdminTeamsPage() { + const tournament = useTournament(); + + const [search, setSearch] = React.useState(""); + const [sort, setSort] = React.useState>(null); + const [exportOpen, setExportOpen] = React.useState(false); + + const maxRosterSize = Math.max( + 1, + ...tournament.ctx.teams.map((team) => team.members.length), + ); + + const filteredTeams = tournament.ctx.teams.filter((team) => + teamMatchesQuery(team, search), + ); + const sortedTeams = sortTeams(filteredTeams, sort); + + return ( +
+
+
+ } + onPress={() => setExportOpen(true)} + > + Export + + } + to={tournamentAdminRegistrationPage(tournament.ctx.id)} + > + Add new team + +
+ setSearch(e.target.value)} + aria-label="Search teams" + placeholder="Search teams" + icon={} + /> +
+ + + + + + + + {Array.from({ length: maxRosterSize }).map((_, i) => ( + + ))} + + + + {sortedTeams.map((team) => ( + + ))} + {sortedTeams.length === 0 ? ( + + + + ) : null} + +
ActionsPlayer {i + 1}
+ No registrations yet +
+ + {exportOpen ? setExportOpen(false)} /> : null} +
+ ); +} + +function TeamRow({ + team, + maxRosterSize, + editPage, +}: { + team: TournamentDataTeam; + maxRosterSize: number; + editPage: string; +}) { + const tournament = useTournament(); + + const members = sortedMembers(team); + const logoSrc = tournament.tournamentTeamLogoSrc(team); + + return ( + + +
+ + {team.team ? ( + + {team.name} + + ) : ( + + {team.name} + + )} +
+ + + + + + + + {Array.from({ length: maxRosterSize }).map((_, i) => { + const member = members[i]; + return ( + + {member ? ( + + {member.role === "OWNER" ? "(C) " : null} + {member.username} + + ) : null} + + ); + })} + + ); +} + +function CheckInCell({ team }: { team: TournamentDataTeam }) { + const tournament = useTournament(); + + const scopes = checkInScopes(tournament, team); + + return ( + + {scopes.map((scope) => + scope.checkedIn ? ( + + ) : ( + + ), + )} + + } + > +
    + {scopes.map((scope) => ( +
  • + {scope.checkedIn ? ( + + ) : ( + + )} + {scope.label} + + {scope.checkedIn ? "Checked in" : "Not checked in"} + +
  • + ))} +
+
+ ); +} + +function TeamRowMenu({ + team, + editPage, +}: { + team: TournamentDataTeam; + editPage: string; +}) { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [confirming, setConfirming] = React.useState< + "DELETE_TEAM" | "DROP_TEAM_OUT" | null + >(null); + + const submit = (body: Record) => + fetcher.submit(body, { method: "post", encType: "application/json" }); + + const checkInOpen = tournament.regularCheckInStartInThePast; + const checkedIn = isTournamentCheckedIn(team); + const bracketsRequiringCheckIn = checkInBracketsForTeam(tournament, team); + const eventLabelSuffix = tournament.brackets.some(isCheckInBracket) + ? " (event)" + : ""; + + return ( +
+ } + to={editPage} + aria-label="Edit registration" + /> + } + aria-label="Actions" + /> + } + > + {checkInOpen && !tournament.hasStarted ? ( + checkedIn ? ( + } + onAction={() => + submit({ _action: "CHECK_OUT", teamId: team.id, bracketIdx: 0 }) + } + > + {`Check out${eventLabelSuffix}`} + + ) : ( + } + onAction={() => + submit({ _action: "CHECK_IN", teamId: team.id, bracketIdx: 0 }) + } + > + {`Check in${eventLabelSuffix}`} + + ) + ) : null} + {team.checkIns.length > 0 + ? bracketsRequiringCheckIn.map((bracket) => { + if (!bracket.preview) return null; + + const bracketCheckedIn = isBracketCheckedIn(team, bracket.idx); + + return bracketCheckedIn ? ( + } + onAction={() => + submit({ + _action: "CHECK_OUT", + teamId: team.id, + bracketIdx: bracket.idx, + }) + } + > + {`Check out (${bracket.name})`} + + ) : ( + } + onAction={() => + submit({ + _action: "CHECK_IN", + teamId: team.id, + bracketIdx: bracket.idx, + }) + } + > + {`Check in (${bracket.name})`} + + ); + }) + : null} + {tournament.hasStarted ? ( + team.droppedOut ? ( + } + onAction={() => + submit({ _action: "UNDO_DROP_TEAM_OUT", teamId: team.id }) + } + > + Undo drop out + + ) : ( + } + isDestructive + onAction={() => setConfirming("DROP_TEAM_OUT")} + > + Drop out + + ) + ) : ( + } + isDestructive + onAction={() => setConfirming("DELETE_TEAM")} + > + Unregister + + )} + + !isOpen && setConfirming(null)} + fields={[ + ["_action", "DELETE_TEAM"], + ["teamId", team.id], + ]} + dialogHeading={`Unregister "${team.name}" and delete its registration info?`} + submitButtonText="Unregister" + /> + !isOpen && setConfirming(null)} + fields={[ + ["_action", "DROP_TEAM_OUT"], + ["teamId", team.id], + ]} + dialogHeading={`Drop "${team.name}" out of the tournament?`} + submitButtonText="Drop out" + /> +
+ ); +} + +function sortedMembers(team: TournamentDataTeam) { + return team.members.toSorted((a, b) => { + if (a.role === "OWNER" && b.role !== "OWNER") return -1; + if (b.role === "OWNER" && a.role !== "OWNER") return 1; + return a.createdAt - b.createdAt; + }); +} + +function isTournamentCheckedIn(team: TournamentDataTeam) { + const tournamentLevel = team.checkIns.filter( + (checkIn) => checkIn.bracketIdx === null, + ); + return ( + tournamentLevel.some((checkIn) => !checkIn.isCheckOut) && + !tournamentLevel.some((checkIn) => checkIn.isCheckOut) + ); +} + +function isBracketCheckedIn(team: TournamentDataTeam, bracketIdx: number) { + return team.checkIns.some( + (checkIn) => checkIn.bracketIdx === bracketIdx && !checkIn.isCheckOut, + ); +} + +/** Does this bracket have its own opt-in check-in (besides the event check-in)? */ +function isCheckInBracket(bracket: Tournament["brackets"][number]) { + return bracket.requiresCheckIn; +} + +/** Is the team going to play (or pending check-in) in this bracket? */ +function isTeamInBracket( + bracket: Tournament["brackets"][number], + teamId: number, +) { + return Boolean( + bracket.seeding?.includes(teamId) || + bracket.teamsPendingCheckIn?.includes(teamId), + ); +} + +/** Check-in brackets the given team is a participant of. */ +function checkInBracketsForTeam( + tournament: Tournament, + team: TournamentDataTeam, +) { + return tournament.brackets.filter( + (bracket) => isCheckInBracket(bracket) && isTeamInBracket(bracket, team.id), + ); +} + +/** The event and the team's check-in brackets paired with its status in each. */ +function checkInScopes(tournament: Tournament, team: TournamentDataTeam) { + return [ + { label: "Event", checkedIn: isTournamentCheckedIn(team) }, + ...checkInBracketsForTeam(tournament, team).map((bracket) => ({ + label: bracket.name, + checkedIn: isBracketCheckedIn(team, bracket.idx), + })), + ]; +} + +function activeCheckInLabels( + team: TournamentDataTeam, + labelFor: (bracketIdx: number | null) => string, +) { + const byBracket = new Map(); + for (const checkIn of team.checkIns) { + const entry = byBracket.get(checkIn.bracketIdx) ?? { + in: false, + out: false, + }; + if (checkIn.isCheckOut) { + entry.out = true; + } else { + entry.in = true; + } + byBracket.set(checkIn.bracketIdx, entry); + } + + const labels: string[] = []; + for (const [bracketIdx, entry] of byBracket) { + if (entry.in && !entry.out) { + labels.push(labelFor(bracketIdx)); + } + } + return labels; +} + +function activeCheckInCount(team: TournamentDataTeam) { + return activeCheckInLabels(team, () => "").length; +} + +function teamMatchesQuery(team: TournamentDataTeam, search: string) { + const query = search.trim(); + if (!query) return true; + + const lowerQuery = query.toLowerCase(); + if (team.name.toLowerCase().includes(lowerQuery)) return true; + if ( + team.members.some((member) => + member.username.toLowerCase().includes(lowerQuery), + ) + ) { + return true; + } + + const identifier = queryToUserIdentifier(query); + if (identifier) { + return team.members.some((member) => { + if ("id" in identifier) return member.userId === identifier.id; + if ("discordId" in identifier) { + return member.discordId === identifier.discordId; + } + return ( + member.customUrl?.toLowerCase() === identifier.customUrl.toLowerCase() + ); + }); + } + + return false; +} + +function sortTeams(teams: TournamentDataTeam[], sort: SortState) { + const bySeed = teams.toSorted((a, b) => { + const aSeed = a.seed ?? Number.POSITIVE_INFINITY; + const bSeed = b.seed ?? Number.POSITIVE_INFINITY; + if (aSeed !== bSeed) return aSeed - bSeed; + return a.createdAt - b.createdAt; + }); + + if (!sort) return bySeed; + + const sorted = bySeed.toSorted((a, b) => + sort.key === "name" + ? a.name.localeCompare(b.name) + : activeCheckInCount(a) - activeCheckInCount(b), + ); + + return sort.dir === "asc" ? sorted : sorted.reverse(); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.module.css b/app/features/tournament-admin/routes/to.$id.admin.audit.module.css new file mode 100644 index 000000000..eb170a1e0 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.module.css @@ -0,0 +1,6 @@ +.userCell { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + white-space: nowrap; +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx new file mode 100644 index 000000000..a64ccbf71 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx @@ -0,0 +1,209 @@ +import { useTranslation } from "react-i18next"; +import { Link, useLoaderData, useSearchParams } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { Label } from "~/components/Label"; +import { LocaleTime } from "~/components/LocaleTime"; +import { Pagination } from "~/components/Pagination"; +import { Table } from "~/components/Table"; +import { TOURNAMENT_AUDIT_LOG_TYPES } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { CommonUser } from "~/utils/kysely.server"; +import { tournamentTeamPage, userPage } from "~/utils/urls"; +import type { TournamentAdminAuditLoader } from "../loaders/to.$id.admin.audit.server"; +import styles from "./to.$id.admin.audit.module.css"; + +export { loader } from "../loaders/to.$id.admin.audit.server"; + +const WHEN_FORMAT_OPTIONS = { + day: "numeric", + month: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", +} as const; + +export default function TournamentAdminAuditLog() { + const { t } = useTranslation(["tournament"]); + const data = useLoaderData(); + const [, setSearchParams] = useSearchParams(); + + const auditLog = data?.auditLog; + if (!auditLog) return null; + + const setPage = (page: number) => { + setSearchParams((params) => { + params.set("page", String(page)); + return params; + }); + }; + + return ( +
+ + {auditLog.events.length === 0 ? ( +
+ {t("tournament:admin.audit.empty")} +
+ ) : ( + <> + + + + + + + + + + + + {auditLog.events.map((event) => ( + + ))} + +
{t("tournament:admin.audit.column.when")}{t("tournament:admin.audit.column.event")}{t("tournament:admin.audit.column.team")}{t("tournament:admin.audit.column.actor")}{t("tournament:admin.audit.column.subject")}
+ {auditLog.pagesCount > 1 ? ( + setPage(auditLog.currentPage + 1)} + previousPage={() => setPage(auditLog.currentPage - 1)} + setPage={setPage} + /> + ) : null} + + )} +
+ ); +} + +type AuditLogEvent = NonNullable< + NonNullable< + ReturnType> + >["auditLog"] +>["events"][number]; + +function AuditLogRow({ event }: { event: AuditLogEvent }) { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + + const detail = + typeof event.metadata?.bracketIdx === "number" + ? tournament.brackets[event.metadata.bracketIdx]?.name + : event.metadata?.inGameName; + + return ( + + + + + + {t(`tournament:admin.audit.event.${event.type}`)} + {detail ?
{detail}
: null} + + + {event.team ? ( + tournament.teamById(event.team.tournamentTeamId) ? ( + + {event.team.name} + + ) : ( + event.team.name + ) + ) : ( + "-" + )} + + + + + + + + + ); +} + +function UserCell({ user }: { user: CommonUser | null }) { + if (!user) return <>-; + + return ( + + + {user.username} + + ); +} + +function AuditLogFilters({ + teams, +}: { + teams: Array<{ id: number; name: string }>; +}) { + const { t } = useTranslation(["tournament"]); + const [searchParams, setSearchParams] = useSearchParams(); + + const setFilter = (key: string, value: string) => { + setSearchParams((params) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + params.delete("page"); + return params; + }); + }; + + return ( +
+
+ + +
+
+ + +
+
+ ); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx new file mode 100644 index 000000000..3c4eb46c4 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.brackets.tsx @@ -0,0 +1,190 @@ +import * as React from "react"; +import { useFetcher } from "react-router"; +import { Divider } from "~/components/Divider"; +import { FormMessage } from "~/components/FormMessage"; +import { Input } from "~/components/Input"; +import { SubmitButton } from "~/components/SubmitButton"; +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; + +export { action } from "../actions/to.$id.admin.brackets.server"; + +export default function TournamentAdminBracketsPage() { + const tournament = useTournament(); + const user = useUser(); + + const showReopen = Boolean( + DANGEROUS_CAN_ACCESS_DEV_CONTROLS && + tournament.ctx.isFinalized && + tournament.isAdmin(user), + ); + const showEditBrackets = + tournament.isAdmin(user) && + tournament.hasStarted && + !tournament.ctx.isFinalized; + + return ( +
+ {showEditBrackets ? ( + <> + Edit brackets + + + ) : null} + {!tournament.isLeagueSignup ? ( + <> + Bracket reset + + + ) : null} + {showReopen ? ( + <> + Reopen tournament (dev only) + + + ) : null} +
+ ); +} + +function BracketReset() { + const tournament = useTournament(); + const fetcher = useFetcher(); + const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); + const [_bracketToDelete, setBracketToDelete] = React.useState( + inProgressBrackets[0]?.id, + ); + const [confirmText, setConfirmText] = React.useState(""); + + if (inProgressBrackets.length === 0) { + return
No brackets in progress
; + } + + const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id; + + const bracketToDeleteName = inProgressBrackets.find( + (bracket) => bracket.id === bracketToDelete, + )?.name; + + return ( +
+ +
+ + +
+
+ + setConfirmText(e.target.value)} + id="bracket-confirmation" + disableAutoComplete + /> +
+ + Reset + +
+ + Resetting a bracket will delete all the match results in it (but not + other brackets) and reset the bracket to its initial state allowing you + to change participating teams. + +
+ ); +} + +function BracketProgressionEdit() { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [bracketProgressionErrored, setBracketProgressionErrored] = + React.useState(false); + + const disabledBracketIdxs = tournament.brackets + .filter((bracket) => !bracket.preview) + .map((bracket) => bracket.idx); + + return ( + + ({ + ...bracket, + disabled: disabledBracketIdxs.includes(idx), + }))} + isInvitationalTournament={tournament.isInvitational} + setErrored={setBracketProgressionErrored} + isTournamentInProgress + /> +
+ + Save changes + +
+
+ ); +} + +function ReopenTournament() { + const tournament = useTournament(); + const fetcher = useFetcher(); + const [confirmText, setConfirmText] = React.useState(""); + + return ( +
+ +
+ + setConfirmText(e.target.value)} + id="reopen-confirmation" + disableAutoComplete + /> +
+ + Reopen + +
+ + Reopening a tournament will delete all results, skill calculations, and + badges awarded from this tournament. Use this to test finalization + multiple times. + +
+ ); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts new file mode 100644 index 000000000..f2dd05bb7 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts @@ -0,0 +1,45 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { z } from "zod"; +import { requireUser } from "~/features/auth/core/user.server"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; +import type { SerializeFrom } from "~/utils/remix"; +import { parseSearchParams } from "~/utils/remix.server"; +import { id } from "~/utils/zod"; + +export type ImportTeamsLoaderData = SerializeFrom; + +/** + * Returns the teams (with rosters) of another tournament so an organizer can + * import one into the registration form they are filling out. + */ +export const loader = async ({ request }: LoaderFunctionArgs) => { + const user = requireUser(); + + const { fromTournamentId } = parseSearchParams({ + request, + schema: z.object({ fromTournamentId: id }), + }); + + const fromTournament = await tournamentFromDB({ + tournamentId: fromTournamentId, + user, + }); + + return { + teams: fromTournament.ctx.teams.map((team) => ({ + id: team.id, + name: team.name, + avatarImgId: team.avatarImgId, + pickupAvatarUrl: team.pickupAvatarUrl, + linkedTeam: team.team + ? { id: team.team.id, logoUrl: team.team.logoUrl } + : null, + members: team.members.map((member) => ({ + userId: member.userId, + username: member.username, + inGameName: member.inGameName, + isOwner: member.role === "OWNER", + })), + })), + }; +}; diff --git a/app/features/tournament-admin/routes/to.$id.admin.index.tsx b/app/features/tournament-admin/routes/to.$id.admin.index.tsx new file mode 100644 index 000000000..d2c00cb67 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.index.tsx @@ -0,0 +1,12 @@ +import { Outlet, useOutletContext } from "react-router"; + +/** + * Layout shared by the admin teams table (index) and the registration editor. + * Rendering them as sibling routes lets the editor take over the content area + * in place of the table instead of opening as a modal on top of it. + */ +export default function TournamentAdminTeamsLayout() { + const outletContext = useOutletContext(); + + return ; +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.module.css b/app/features/tournament-admin/routes/to.$id.admin.module.css new file mode 100644 index 000000000..41be691d0 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.module.css @@ -0,0 +1,3 @@ +.panel { + min-width: 0; +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx new file mode 100644 index 000000000..95630db32 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx @@ -0,0 +1,376 @@ +import { ArrowLeft, Import } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useFetcher, useParams } from "react-router"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { SendouDialog } from "~/components/elements/Dialog"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { FormField } from "~/form/FormField"; +import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import type { + ArrayItemRenderContext, + SelectOption, + TeamSearchFieldOptions, +} from "~/form/types"; +import { + tournamentAdminImportTeamsPage, + tournamentAdminPage, +} from "~/utils/urls"; +import { + type AdminRegistrationFormValues, + adminRegistrationFormSchema, + type ImportTeamFormValues, + importTeamFormSchema, +} from "../tournament-admin-registration-schemas"; +import type { ImportTeamsLoaderData } from "./to.$id.admin.import-teams"; + +export { action } from "../actions/to.$id.admin.registration.server"; + +type RosterMemberValue = { + userId?: number; + inGameName?: string | null; +}; + +type ImportableTeam = ImportTeamsLoaderData["teams"][number]; + +type LinkedTeamPrefill = { + id: number; + name: string; + avatarUrl?: string | null; +}; + +export default function TournamentAdminRegistrationPage() { + const { t } = useTranslation(["common"]); + const tournament = useTournament(); + const { tid } = useParams(); + + const team = + typeof tid === "string" ? tournament.teamById(Number(tid)) : undefined; + + const adminPage = tournamentAdminPage(tournament.ctx.id); + + const owner = team?.members.find((member) => member.role === "OWNER"); + + const defaultValues: Partial | undefined = team + ? { + tournamentTeamId: team.id, + linkedTeam: Boolean(team.team), + pickUpName: team.team ? null : team.name, + logo: + !team.team && + team.pickupAvatarUrl && + typeof team.avatarImgId === "number" + ? { + type: "EXISTING", + imgId: team.avatarImgId, + url: team.pickupAvatarUrl, + } + : null, + teamId: team.team?.id ?? null, + ownerId: owner ? String(owner.userId) : "", + members: team.members.map((member) => ({ + userId: member.userId, + inGameName: member.inGameName ?? null, + })), + } + : undefined; + + return ( +
+ } + className="mr-auto" + > + {t("common:actions.back")} + + + + +
+ ); +} + +function RegistrationFields({ team }: { team?: TournamentDataTeam }) { + const { t } = useTranslation(["forms"]); + const tournament = useTournament(); + const { values, setValue, revalidateAll, hasSubmitted } = + useFormFieldContext(); + + const [usernames, setUsernames] = React.useState>( + () => { + const initial: Record = {}; + for (const member of team?.members ?? []) { + initial[member.userId] = member.username; + } + return initial; + }, + ); + const [importedLinkedTeam, setImportedLinkedTeam] = + React.useState(null); + + const linkedTeam = Boolean(values.linkedTeam); + const members = (values.members as RosterMemberValue[]) ?? []; + const requireInGameNames = tournament.ctx.settings.requireInGameNames; + + const handleImport = (importedTeam: ImportableTeam) => { + setUsernames((prev) => { + const next = { ...prev }; + for (const member of importedTeam.members) { + next[member.userId] = member.username; + } + return next; + }); + + const owner = + importedTeam.members.find((member) => member.isOwner) ?? + importedTeam.members[0]; + + const importedValues: Record = importedTeam.linkedTeam + ? { linkedTeam: true, teamId: importedTeam.linkedTeam.id } + : { + linkedTeam: false, + pickUpName: importedTeam.name, + logo: + typeof importedTeam.avatarImgId === "number" && + importedTeam.pickupAvatarUrl + ? { + type: "EXISTING", + imgId: importedTeam.avatarImgId, + url: importedTeam.pickupAvatarUrl, + } + : null, + }; + importedValues.members = importedTeam.members.map((member) => ({ + userId: member.userId, + inGameName: member.inGameName, + // fresh key so the member rows remount and their user-search inputs + // re-resolve when importing a different team over a previous import + _key: crypto.randomUUID(), + })); + importedValues.ownerId = owner ? String(owner.userId) : ""; + + setImportedLinkedTeam( + importedTeam.linkedTeam + ? { + id: importedTeam.linkedTeam.id, + name: importedTeam.name, + avatarUrl: importedTeam.linkedTeam.logoUrl, + } + : null, + ); + + for (const [name, value] of Object.entries(importedValues)) { + setValue(name, value); + } + + // if the form was already submitted, recompute against the imported values + // so stale "required" errors don't linger on now-filled fields; before any + // submit there are no errors to surface yet + if (hasSubmitted) { + revalidateAll({ ...values, ...importedValues }); + } + }; + + const ownerOptions: SelectOption[] = members + .filter( + (member): member is { userId: number } => + typeof member.userId === "number", + ) + .map((member, i) => ({ + value: String(member.userId), + label: usernames[member.userId] ?? `${t("forms:labels.player")} ${i + 1}`, + })); + + return ( + <> + {!team ? ( + + ) : null} + + {linkedTeam ? ( + { + if (!selected) return; + setUsernames((prev) => { + const next = { ...prev }; + for (const member of selected.members) { + next[member.id] = member.username; + } + return next; + }); + setValue( + "members", + selected.members.map((member) => ({ + userId: member.id, + inGameName: null, + })), + ); + setValue("ownerId", String(selected.members[0]?.id ?? "")); + }, + } satisfies TeamSearchFieldOptions + } + /> + ) : ( + <> + + + + )} + + {({ itemName }: ArrayItemRenderContext) => ( +
+ + {requireInGameNames ? ( + + ) : null} +
+ )} +
+ + + ); +} + +function ImportTeamSection({ + currentTournamentId, + onImport, +}: { + currentTournamentId: number; + onImport: (team: ImportableTeam) => void; +}) { + const { t } = useTranslation(["forms"]); + const [isOpen, setIsOpen] = React.useState(false); + const teamsRef = React.useRef([]); + + const handleApply = (values: ImportTeamFormValues) => { + const importedTeam = teamsRef.current.find( + (team) => String(team.id) === values.sourceTournamentTeamId, + ); + if (importedTeam) { + onImport(importedTeam); + } + setIsOpen(false); + }; + + return ( +
+ } + onPress={() => setIsOpen(true)} + > + {t("forms:regImportTeam")} + + {isOpen ? ( + setIsOpen(false)} + > + {/* The modal is portaled out of the registration
in the DOM, + but its submit event still bubbles through the React tree to the + outer form. Stop it here so importing doesn't submit registration. */} +
e.stopPropagation()}> + + + +
+ + ) : null} +
+ ); +} + +function ImportTeamFields({ + currentTournamentId, + teamsRef, +}: { + currentTournamentId: number; + teamsRef: React.RefObject; +}) { + const { values, setValue } = useFormFieldContext(); + const fetcher = useFetcher(); + + const sourceTournamentId = + typeof values.sourceTournamentId === "number" + ? values.sourceTournamentId + : null; + + const loadedForRef = React.useRef(null); + React.useEffect(() => { + if (sourceTournamentId === null) return; + if (loadedForRef.current === sourceTournamentId) return; + if (fetcher.state !== "idle") return; + + loadedForRef.current = sourceTournamentId; + fetcher.load( + tournamentAdminImportTeamsPage({ + tournamentId: currentTournamentId, + fromTournamentId: sourceTournamentId, + }), + ); + }, [sourceTournamentId, currentTournamentId, fetcher]); + + const teams = fetcher.data?.teams ?? []; + teamsRef.current = teams; + + // the non-clearable native select visually shows the first option, so keep the + // form value in sync with it: default to the first team and reset when the + // loaded set of teams changes (e.g. after picking a different tournament) + const teamIdsKey = teams.map((team) => team.id).join(","); + // biome-ignore lint/correctness/useExhaustiveDependencies: values/setValue read from closure; resync only when the loaded teams change + React.useEffect(() => { + if (teams.length === 0) return; + const current = values.sourceTournamentTeamId; + if ( + typeof current === "string" && + teams.some((team) => String(team.id) === current) + ) { + return; + } + setValue("sourceTournamentTeamId", String(teams[0].id)); + }, [teamIdsKey]); + + const teamOptions: SelectOption[] = teams.map((team) => ({ + value: String(team.id), + label: team.name, + })); + + return ( + <> + + + + ); +} diff --git a/app/features/tournament/routes/to.$id.seeds.module.css b/app/features/tournament-admin/routes/to.$id.admin.seeds.module.css similarity index 100% rename from app/features/tournament/routes/to.$id.seeds.module.css rename to app/features/tournament-admin/routes/to.$id.admin.seeds.module.css diff --git a/app/features/tournament/routes/to.$id.seeds.tsx b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx similarity index 97% rename from app/features/tournament/routes/to.$id.seeds.tsx rename to app/features/tournament-admin/routes/to.$id.admin.seeds.tsx index b74416eca..6c771ad2e 100644 --- a/app/features/tournament/routes/to.$id.seeds.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.seeds.tsx @@ -21,7 +21,6 @@ import * as React from "react"; import { Link, useFetcher, useNavigation } from "react-router"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; -import { Catcher } from "~/components/Catcher"; import { SendouButton } from "~/components/elements/Button"; import { SendouChipRadio, @@ -33,19 +32,17 @@ import { InfoPopover } from "~/components/InfoPopover"; import { SubmitButton } from "~/components/SubmitButton"; import { Table } from "~/components/Table"; import type { SeedingSnapshot } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import invariant from "~/utils/invariant"; import { navIconUrl, userResultsPage } from "~/utils/urls"; import { ordinalToRoundedSp } from "../../mmr/mmr-utils"; -import { action } from "../actions/to.$id.seeds.server"; -import { loader } from "../loaders/to.$id.seeds.server"; -import { TOURNAMENT } from "../tournament-constants"; -import { useTournament } from "./to.$id"; -import styles from "./to.$id.seeds.module.css"; +import styles from "./to.$id.admin.seeds.module.css"; -export { action, loader }; +export { action } from "../actions/to.$id.admin.seeds.server"; const AB_DIVISION_RADIO_OPTIONS = [ { value: "unassigned", label: "Unassigned" }, @@ -53,7 +50,7 @@ const AB_DIVISION_RADIO_OPTIONS = [ { value: "1", label: "B" }, ] as const; -export default function TournamentSeedsPage() { +export default function TournamentAdminSeedsPage() { const tournament = useTournament(); const navigation = useNavigation(); const [teamOrder, setTeamOrder] = React.useState( @@ -726,9 +723,7 @@ function RowContents({
- - {team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name} - + {team.name} {isNewTeam ? NEW : null}
@@ -849,5 +844,3 @@ function computeRemovedPlayers( } return result; } - -export const ErrorBoundary = Catcher; diff --git a/app/features/tournament-admin/routes/to.$id.admin.staff.tsx b/app/features/tournament-admin/routes/to.$id.admin.staff.tsx new file mode 100644 index 000000000..082821acc --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.staff.tsx @@ -0,0 +1,215 @@ +import { SquarePen } from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Avatar } from "~/components/Avatar"; +import { Divider } from "~/components/Divider"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { type Tables, TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { SendouForm } from "~/form/SendouForm"; +import { tournamentOrganizationEditPage } from "~/utils/urls"; +import { adminStaffFormSchema } from "../tournament-admin-staff-schemas"; + +export { action } from "../actions/to.$id.admin.staff.server"; + +export default function TournamentAdminStaffPage() { + const [isEditing, setIsEditing] = React.useState(false); + const tournament = useTournament(); + + const staff = tournament.ctx.staff.filter( + (staffer) => staffer.id !== tournament.ctx.author.id, + ); + + return ( +
+ + {isEditing ? ( + setIsEditing(false)} + onCancel={() => setIsEditing(false)} + /> + ) : ( + setIsEditing(true)} /> + )} +
+ ); +} + +type EventStaff = Array< + Pick & { + role: Tables["TournamentStaff"]["role"]; + } +>; + +function AddedForEventForm({ + staff, + onSuccess, + onCancel, +}: { + staff: EventStaff; + onSuccess: () => void; + onCancel: () => void; +}) { + const { t } = useTranslation(["common"]); + + return ( + + {t("common:actions.cancel")} + + } + defaultValues={{ + staff: staff.map((staffer) => ({ + userId: staffer.id, + role: staffer.role, + })), + }} + > + {({ FormField }) => ( + + + + )} + + ); +} + +function AddedForEventRows({ + staff, + onEdit, +}: { + staff: EventStaff; + onEdit: () => void; +}) { + const { t } = useTranslation(["common", "tournament"]); + + return ( + + {staff.length > 0 ? ( +
+ {staff.map((staffer) => ( + + ))} +
+ ) : null} + } + variant="outlined" + size="small" + onPress={onEdit} + className="m-0-auto" + data-testid="edit-staff-button" + > + {t("common:actions.edit")} + +
+ ); +} + +function AddedForEventSection({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(["tournament"]); + + return ( +
+ {t("tournament:staff.divider.addedForEvent")} + {children} +
+ ); +} + +const ORGANIZATION_STAFF_ROLES: ReadonlyArray< + Tables["TournamentOrganizationMember"]["role"] +> = TOURNAMENT_ORGANIZATION_ROLES.filter((role) => role !== "MEMBER"); + +/** + * Users who already have staff permissions implicitly (the tournament author + * and organization staff) shown for info only - they can't be edited or removed. + */ +function ImplicitStaffRows() { + const { t } = useTranslation(["tournament"]); + const tournament = useTournament(); + const author = tournament.ctx.author; + const organization = tournament.ctx.organization; + + const organizationStaff = (organization?.members ?? []).filter( + (member) => + member.userId !== author.id && + ORGANIZATION_STAFF_ROLES.includes(member.role), + ); + + return ( +
+ + {t("tournament:staff.divider.fromOrganization")} + +
+ + {organizationStaff.map((member) => { + const roleKey = member.role === "STREAMER" ? "STREAMER" : "ORGANIZER"; + + return ( + + ); + })} +
+ {organization ? ( + } + variant="outlined" + size="small" + className="m-0-auto" + testId="edit-org-button" + > + {t("tournament:staff.editOrganization")} + + ) : null} +
+ ); +} + +function StaffInfoRow({ + user, + roleText, + testId, +}: { + user: Pick; + roleText: string; + testId?: string; +}) { + return ( +
+ +
+
{user.username}
+
{roleText}
+
+
+ ); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.stream.tsx b/app/features/tournament-admin/routes/to.$id.admin.stream.tsx new file mode 100644 index 000000000..38a6a35d2 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.stream.tsx @@ -0,0 +1,22 @@ +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { SendouForm } from "~/form/SendouForm"; +import { adminStreamFormSchema } from "../tournament-admin-staff-schemas"; + +export { action } from "../actions/to.$id.admin.stream.server"; + +export default function TournamentAdminStreamPage() { + const tournament = useTournament(); + + return ( + + {({ FormField }) => } + + ); +} diff --git a/app/features/tournament-admin/routes/to.$id.admin.tsx b/app/features/tournament-admin/routes/to.$id.admin.tsx new file mode 100644 index 000000000..0071e3de0 --- /dev/null +++ b/app/features/tournament-admin/routes/to.$id.admin.tsx @@ -0,0 +1,157 @@ +import clsx from "clsx"; +import { + History, + ListOrdered, + SquarePen, + Trophy, + Tv, + UserCog, + Users, +} from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Outlet, useLocation, useOutletContext } from "react-router"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; +import { containerClassName } from "~/components/Main"; +import { Redirect } from "~/components/Redirect"; +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; +import { useUser } from "~/features/auth/core/user"; +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + calendarEventPage, + tournamentAdminPage, + tournamentEditPage, + tournamentPage, +} from "~/utils/urls"; +import styles from "./to.$id.admin.module.css"; + +export { action } from "../actions/to.$id.admin.index.server"; + +const HORIZONTAL_TABS_BELOW = 720; + +type AdminTab = "teams" | "seeds" | "staff" | "stream" | "brackets" | "audit"; + +export default function TournamentAdminLayout() { + const { t } = useTranslation(["tournament", "calendar"]); + const tournament = useTournament(); + const outletContext = useOutletContext(); + const user = useUser(); + const location = useLocation(); + + const showReopen = Boolean( + DANGEROUS_CAN_ACCESS_DEV_CONTROLS && + tournament.ctx.isFinalized && + tournament.isAdmin(user), + ); + const showEditBrackets = + tournament.isAdmin(user) && + tournament.hasStarted && + !tournament.ctx.isFinalized; + const showStaffTab = tournament.isAdmin(user); + const showBracketsTab = + !tournament.isLeagueSignup || showEditBrackets || showReopen; + const showSeedsTab = !tournament.hasStarted && !tournament.isLeagueSignup; + + if ( + !tournament.isOrganizer(user) || + (tournament.ctx.isFinalized && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS) + ) { + return ; + } + + const adminPage = tournamentAdminPage(tournament.ctx.id); + const subPath = location.pathname.slice(adminPage.length).replace(/^\//, ""); + const currentTab: AdminTab = + subPath === "" || subPath.startsWith("registration") + ? "teams" + : (subPath as AdminTab); + + return ( +
+ {tournament.isAdmin(user) && !tournament.hasStarted ? ( +
+ } + testId="edit-event-info-button" + > + Edit event info + + {!tournament.isLeagueSignup ? ( + + + {t("calendar:actions.delete")} + + + ) : null} +
+ ) : null} + + + }> + {t("tournament:admin.tab.teams")} + + {showSeedsTab ? ( + } + > + {t("tournament:admin.tab.seeds")} + + ) : null} + {showStaffTab ? ( + } + > + {t("tournament:admin.tab.staff")} + + ) : null} + }> + {t("tournament:admin.tab.stream")} + + {showBracketsTab ? ( + } + > + {t("tournament:admin.tab.brackets")} + + ) : null} + }> + {t("tournament:admin.tab.audit")} + + + + + + +
+ ); +} diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts new file mode 100644 index 000000000..c3a03badb --- /dev/null +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts @@ -0,0 +1,146 @@ +import { z } from "zod"; +import { userIsBanned } from "~/features/ban/core/banned.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import { tournamentTeamNameTaken } from "~/features/tournament/tournament-utils.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { adminRegistrationFormSchema } from "./tournament-admin-registration-schemas"; + +/** + * Extends the client {@link adminRegistrationFormSchema} with server-only, + * context-dependent validations that surface as field errors (rather than toasts): + * unique team name, roster size limit, and per-member friend code / in-game name / + * ban / already-on-another-team checks. + */ +export function adminRegistrationFormSchemaServer({ + tournament, +}: { + tournament: Tournament; +}) { + return adminRegistrationFormSchema.superRefine(async (data, ctx) => { + const name = data.linkedTeam + ? typeof data.teamId === "number" + ? (await TeamRepository.findById(data.teamId))?.name + : undefined + : data.pickUpName; + if ( + name != null && + tournamentTeamNameTaken({ + tournament, + name, + exceptTournamentTeamId: data.tournamentTeamId ?? undefined, + }) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regTeamNameTaken", + path: [data.linkedTeam ? "teamId" : "pickUpName"], + }); + } + + if (data.members.length > tournament.maxMembersPerTeam) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regTooManyMembers", + path: ["members"], + }); + } + + const team = + typeof data.tournamentTeamId === "number" + ? tournament.teamById(data.tournamentTeamId) + : undefined; + const currentMemberIds = team?.members.map((member) => member.userId) ?? []; + + if (team) { + const submittedMemberIds = data.members.map((member) => member.userId); + const membersToRemove = currentMemberIds.filter( + (memberId) => !submittedMemberIds.includes(memberId), + ); + + if (tournament.hasStarted) { + const participatedPlayerIds = tournament + .participatedPlayersByTeamId(team.id) + .map((player) => player.userId); + const removingParticipatedPlayer = membersToRemove.some((memberId) => + participatedPlayerIds.includes(memberId), + ); + if (removingParticipatedPlayer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regCannotRemoveParticipatedPlayer", + path: ["members"], + }); + } + } + + if ( + team.checkIns.length > 0 && + data.members.length < tournament.minMembersPerTeam + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regCheckedInBelowMinRoster", + path: ["members"], + }); + } + } + + for (const [index, member] of data.members.entries()) { + const path = ["members", index, "userId"]; + + const memberUser = await UserRepository.findLeanById(member.userId); + if (!memberUser) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regMemberInvalid", + path, + }); + continue; + } + + if (!memberUser.friendCode) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regMemberNoFriendCode", + path, + }); + } + + if ( + tournament.ctx.settings.requireInGameNames && + !memberUser.inGameName + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regMemberNoInGameName", + path, + }); + } + + // only members not already on the team are subject to ban / other-team checks + if (currentMemberIds.includes(member.userId)) continue; + + if (userIsBanned(member.userId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regMemberBanned", + path, + }); + } + + const previousTeam = tournament.teamMemberOfByUser({ id: member.userId }); + if ( + previousTeam && + previousTeam.id !== team?.id && + !tournament.hasStarted + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regMemberOnAnotherTeam", + path, + }); + } + } + }); +} diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.ts new file mode 100644 index 000000000..e07e56b9e --- /dev/null +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.ts @@ -0,0 +1,123 @@ +import { z } from "zod"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { + array, + fieldset, + idConstantOptional, + image, + selectDynamic, + stringConstant, + teamSearchOptional, + textFieldOptional, + toggle, + tournamentSearchOptional, + userSearch, +} from "~/form/fields"; +import { TEAM } from "../team/team-constants"; +import { IN_GAME_NAME_REGEXP } from "../user-page/user-page-constants"; + +/** Combined in-game name e.g. `Sendou#1234` is at most 10 + `#` + 5 characters. */ +const IN_GAME_NAME_MAX_LENGTH = 16; + +const memberFieldset = fieldset({ + fields: z.object({ + userId: userSearch({ label: "labels.player" }), + inGameName: textFieldOptional({ + label: "labels.inGameName", + maxLength: IN_GAME_NAME_MAX_LENGTH, + regExp: { + pattern: IN_GAME_NAME_REGEXP, + message: "forms:errors.profileInGameName", + }, + }), + }), +}); + +export const adminRegistrationFormSchema = z + .object({ + _action: stringConstant("UPSERT_REGISTRATION"), + /** Present when editing an existing registration, absent when adding a new team. */ + tournamentTeamId: idConstantOptional(), + /** false = pickup team (typed name), true = linked sendou.ink team. */ + linkedTeam: toggle({ label: "labels.regLinkedTeam" }), + pickUpName: textFieldOptional({ + label: "labels.regTeamName", + maxLength: TOURNAMENT.TEAM_NAME_MAX_LENGTH, + }), + /** Pickup team logo. Linked teams source their logo from the sendou.ink team instead. */ + logo: image({ label: "labels.logo" }), + teamId: teamSearchOptional({ label: "labels.regTeam" }), + /** `String(userId)` of the roster member that is the team owner/captain. */ + ownerId: selectDynamic({ label: "labels.regCaptain" }), + members: array({ + label: "labels.members", + min: 1, + max: TEAM.MAX_MEMBER_COUNT, + field: memberFieldset, + }), + }) + .superRefine((data, ctx) => { + if (data.linkedTeam) { + if (typeof data.teamId !== "number") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regLinkedTeamRequired", + path: ["teamId"], + }); + } + } else if (!data.pickUpName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regTeamNameRequired", + path: ["pickUpName"], + }); + } + + const memberIds = data.members.map((member) => member.userId); + if (memberIds.length !== new Set(memberIds).size) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.usersMustBeUnique", + path: ["members"], + }); + } + + if (!memberIds.some((memberId) => String(memberId) === data.ownerId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regOwnerMustBeMember", + path: ["ownerId"], + }); + } + }); + +export type AdminRegistrationFormValues = z.input< + typeof adminRegistrationFormSchema +>; + +/** + * Modal form used to import an existing team's roster from another tournament + * into the {@link adminRegistrationFormSchema} when adding a new team. Validated + * client-side only — submitting prefills the registration form rather than + * hitting the server. + */ +export const importTeamFormSchema = z + .object({ + sourceTournamentId: tournamentSearchOptional({ + label: "labels.regImportSourceTournament", + }), + sourceTournamentTeamId: selectDynamic({ + label: "labels.regTeam", + }), + }) + .superRefine((data, ctx) => { + if (typeof data.sourceTournamentId !== "number") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regImportTournamentRequired", + path: ["sourceTournamentId"], + }); + } + }); + +export type ImportTeamFormValues = z.input; diff --git a/app/features/tournament-admin/tournament-admin-schemas.server.ts b/app/features/tournament-admin/tournament-admin-schemas.server.ts new file mode 100644 index 000000000..98bc62267 --- /dev/null +++ b/app/features/tournament-admin/tournament-admin-schemas.server.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { _action, id, safeJSONParse } from "~/utils/zod"; +import { bracketProgressionSchema } from "../calendar/calendar-schemas"; +import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; +import { adminStaffFormSchema } from "./tournament-admin-staff-schemas"; + +/** + * Extends the client {@link adminStaffFormSchema} with a server-only, + * context-dependent validation: the tournament author can't be added as staff + * (they are always shown as an organizer for info only). + */ +export function adminStaffFormSchemaServer({ + tournament, +}: { + tournament: Tournament; +}) { + return adminStaffFormSchema.superRefine((data, ctx) => { + for (const [index, staffer] of data.staff.entries()) { + if (staffer.userId === tournament.ctx.author.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.staffCannotBeAuthor", + path: ["staff", index, "userId"], + }); + } + } + }); +} + +export const adminTeamsActionSchema = z.union([ + z.object({ + _action: _action("CHECK_IN"), + teamId: id, + bracketIdx, + }), + z.object({ + _action: _action("CHECK_OUT"), + teamId: id, + bracketIdx, + }), + z.object({ + _action: _action("DELETE_TEAM"), + teamId: id, + }), + z.object({ + _action: _action("DROP_TEAM_OUT"), + teamId: id, + }), + z.object({ + _action: _action("UNDO_DROP_TEAM_OUT"), + teamId: id, + }), +]); + +export const adminBracketsActionSchema = z.union([ + z.object({ + _action: _action("RESET_BRACKET"), + stageId: id, + }), + z.object({ + _action: _action("UPDATE_TOURNAMENT_PROGRESSION"), + bracketProgression: bracketProgressionSchema, + }), + z.object({ + _action: _action("REOPEN_TOURNAMENT"), + }), +]); + +export const adminSeedsActionSchema = z.union([ + z.object({ + _action: _action("UPDATE_SEEDS"), + seeds: z.preprocess(safeJSONParse, z.array(id)), + }), + z.object({ + _action: _action("UPDATE_STARTING_BRACKETS"), + startingBrackets: z.preprocess( + safeJSONParse, + z.array( + z.object({ + tournamentTeamId: id, + startingBracketIdx: bracketIdx, + }), + ), + ), + }), + z.object({ + _action: _action("UPDATE_AB_DIVISIONS"), + abDivisions: z.preprocess( + safeJSONParse, + z.array( + z.object({ + tournamentTeamId: id, + abDivision: z.union([z.literal(0), z.literal(1), z.null()]), + }), + ), + ), + }), +]); diff --git a/app/features/tournament-admin/tournament-admin-staff-schemas.ts b/app/features/tournament-admin/tournament-admin-staff-schemas.ts new file mode 100644 index 000000000..91c6345f4 --- /dev/null +++ b/app/features/tournament-admin/tournament-admin-staff-schemas.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { TOURNAMENT_STAFF_ROLES } from "~/db/tables"; +import { + array, + fieldset, + select, + textFieldRequired, + userSearch, +} from "~/form/fields"; + +export const adminStreamFormSchema = z.object({ + castTwitchAccounts: array({ + label: "labels.castTwitchAccounts", + bottomText: "bottomTexts.castTwitchAccounts", + max: 5, + field: textFieldRequired({ + maxLength: 100, + placeholder: "placeholders.castTwitchAccounts", + }), + }), +}); + +export const adminStaffFormSchema = z + .object({ + staff: array({ + bottomText: "bottomTexts.staffRolesInfo", + max: 50, + field: fieldset({ + fields: z.object({ + userId: userSearch({ label: "labels.user" }), + role: select({ + label: "labels.staffRole", + items: TOURNAMENT_STAFF_ROLES.map((role) => ({ + value: role, + label: `options.staffRole.${role}` as const, + })), + }), + }), + }), + }), + }) + .superRefine((data, ctx) => { + const userIds = data.staff.map((staffer) => staffer.userId); + if (userIds.length !== new Set(userIds).size) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.usersMustBeUnique", + path: ["staff"], + }); + } + }); diff --git a/app/features/tournament-admin/tournament-admin-utils.server.ts b/app/features/tournament-admin/tournament-admin-utils.server.ts new file mode 100644 index 000000000..999c1c334 --- /dev/null +++ b/app/features/tournament-admin/tournament-admin-utils.server.ts @@ -0,0 +1,19 @@ +import type { AuthenticatedUser } from "~/features/auth/core/user.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { errorToastIfFalsy } from "~/utils/remix.server"; + +/** Throws an error toast unless the user is an organizer of the tournament. */ +export function requireTournamentOrganizer( + tournament: Tournament, + user: AuthenticatedUser, +) { + errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); +} + +/** Throws an error toast unless the user is an admin of the tournament. */ +export function requireTournamentAdmin( + tournament: Tournament, + user: AuthenticatedUser, +) { + errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized"); +} diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 369e93b27..627f5ad4d 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -103,7 +103,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) { className={clsx(styles.elimRoundMatchesContainer, { [styles.elimRoundMatchesContainerTopBye]: !atLeastOneColumnHidden && - props.type === "winners" && + (props.type === "winners" || props.type === "single") && (!props.bracket.data.match[0].opponent1 || !props.bracket.data.match[0].opponent2), })} diff --git a/app/features/tournament-bracket/components/Bracket/bracket.module.css b/app/features/tournament-bracket/components/Bracket/bracket.module.css index 6f703f7e1..96dd6aaec 100644 --- a/app/features/tournament-bracket/components/Bracket/bracket.module.css +++ b/app/features/tournament-bracket/components/Bracket/bracket.module.css @@ -12,13 +12,51 @@ .scrollingBracket { padding: var(--s-4) var(--s-6); max-width: 100%; - max-height: min(1000px, 70vh); - -ms-overflow-style: none; + max-height: min(1200px, 82dvh); scrollbar-width: none; border: var(--border-style); border-radius: var(--radius-box); overflow: scroll; user-select: none; + + /* Inside a breakout wrapper (see `mainBreakout`) grow only as wide as the + bracket actually needs: never narrower than the normal page width, never + wider than the content area (then it scrolls), centered either way. + Height fills down to the bottom of the viewport using the top offset + published by JS (`--bracket-fill-top`), floored so it never collapses. */ + :global([data-main-breakout]) & { + width: fit-content; + min-width: min(72rem, 100%); + margin-inline: auto; + max-height: max( + 300px, + calc(100dvh - var(--bracket-fill-top, 30dvh) - var(--s-6)) + ); + + /* The mobile bottom nav is fixed below 600px; keep clear of it. */ + @media screen and (max-width: 599.98px) { + max-height: max( + 300px, + calc( + 100dvh - + var(--bracket-fill-top, 30dvh) - + var(--layout-nav-height) - + env(safe-area-inset-bottom) - + var(--s-3) + ) + ); + } + } +} + +/* Lets the bracket size against the full content area instead of the page + width. cqw resolves to
, which spans the content area in breakout + mode (its own padding keeps the bracket off the edges). */ +.breakoutWrapper { + :global([data-main-breakout]) & { + width: 100cqw; + margin-inline: calc(50% - 50cqw); + } } .matchHeader { diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx index daa511399..024d46629 100644 --- a/app/features/tournament-bracket/components/Bracket/index.tsx +++ b/app/features/tournament-bracket/components/Bracket/index.tsx @@ -2,6 +2,7 @@ import clsx from "clsx"; import * as React from "react"; import { useDraggable } from "react-use-draggable-scroll"; import { useBracketExpanded } from "~/features/tournament/routes/to.$id"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import type { Bracket as BracketType } from "../../core/Bracket"; import styles from "./bracket.module.css"; import { EliminationBracketSide } from "./Elimination"; @@ -90,15 +91,50 @@ function ScrollableBracketContainer({ const { events } = useDraggable(ref, { applyRubberBandEffect: true, }); + usePublishBracketTopOffset(ref); return ( -
- {children} +
+
+ {children} +
); } + +/** + * Inside a breakout container (see `mainBreakout`), publishes the bracket's + * distance from the top of the viewport as the `--bracket-fill-top` CSS + * variable. The bracket's `max-height` is then derived from it in CSS, which + * can account for the viewport, the mobile bottom nav and safe area insets in + * ways JS can't read. A no-op elsewhere, so the static `max-height` applies. + */ +function usePublishBracketTopOffset(ref: React.RefObject) { + useIsomorphicLayoutEffect(() => { + const el = ref.current; + if (!el?.closest("[data-main-breakout]")) return; + + const update = () => { + el.style.setProperty( + "--bracket-fill-top", + `${el.getBoundingClientRect().top}px`, + ); + }; + + update(); + + const observer = new ResizeObserver(update); + observer.observe(document.body); + window.addEventListener("resize", update); + + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, [ref]); +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 958a420cf..f71281602 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -631,6 +631,15 @@ export class Tournament { return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool); } + /** Should the rules page (and its nav item) be shown. True if there are rules or any map pool to show. */ + get hasRulesPage() { + return ( + this.ctx.hasRules || + this.ctx.toSetMapPool.length > 0 || + this.ctx.tieBreakerMapPool.length > 0 + ); + } + /** Tournament teams logo image path, either from the team or the pickup avatar uploaded specifically for this tournament */ tournamentTeamLogoSrc(team: TournamentDataTeam) { return team.team?.logoUrl ?? team.pickupAvatarUrl; diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index c6728b369..97a38a06e 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -58,6 +58,7 @@ describe("tournamentSummary()", () => { team: null, seed: 1, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, }); diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 9fbbbbe56..f30984f93 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -6915,17 +6915,15 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ }, seedingSnapshot: null, mapPickingStyle: "TO", - rules: - "# Important Notices\n\nEach team's captain must be in the Inking Performance Labs Discord. Before registering, join the discord here, and then get the Low Ink role using the Channels & Roles feature.\n\nRegistration for Low Ink closes 17 hours prior to the event starting which is 8 PM ET (day before) (5 PM PT (day before), 1 AM GMT/UTC (day of), 2 AM CET (day of)).\n\nBy registering for the tournament, you agree to and consent to follow all rules in Low Ink, and promise to read all announcements leading up to the event. In compliance with Nintendo tournament guidelines, you also consent that by registering for this tournament, Inkling Performance Labs may use videos, still images, etc. of me and my teammates' gameplay from Inkling Performance Labs events for monetization.\n\nNintendo is not a sponsor of or affiliated with this tournament.\nTerms for participating in and viewing Community Tournaments using Nintendo Games can be found at the following URL: https://en-americas-support.nintendo.com/app/answers/detail/a_id/63454\n\n# Full Rules\n\nLow Ink's full ruleset (which is too large to include here) can be found in our [rules document](https://docs.google.com/document/d/1sMUOeRe8isLuu4Koco_qRvwMa2oV1_1LQZ0Vt3_IBt4/pub). **By registering, you and your team agree to follow all rules in the rules document.**", + hasRules: true, name: "Low Ink December 2024", - description: - "# Low Ink is a Splatoon 3 tournament for newer and lower-level competitive teams.\n\nWe give teams a chance to get into the Splatoon 3 tournament scene and gain valuable competitive experience.\n\n## What makes Low Ink good for newer players?\n• The tournament bans players with tourney results exceeding the skill cap.\n• An easy to follow format on day 1, which guarantees 18 games played with multiple teams.\n• Multiple brackets on Day 2 for the top cut of teams, with placements determined by day 1 performance.\n• Quality live stream and commentary with a focus on introducing competitive Splatoon to newcomers.\n\n## Who can play?\n\nIf this is your first time playing in a Splatoon tournament, you are allowed to play. Low Ink bans players based on results from community-driven tournaments. Solo queue results or in-game anarchy rank is not a factor. Results from other competitive events or participation in other tournaments are not required to play in Low Ink, they only set the skill cap. More information about eligibility is available on the rules page or in the Low Ink FAQ in our [discord server](https://iplabs.ink/discord).\n\n## How to signup and play\n\nSign up on sendou.ink (this page). The site will guide you through the sign-up process. Once signed up, have at least the captain of your team join our discord server to access important announcements alongside other Low Ink channels, get the Low Ink role through the Channels & Roles feature when you join!\n\nDuring the tournament, your matches will appear here on sendou.ink and you can chat with your opponent, and report scores here. Tournament announcements and helpdesk (for contacting staff) is done on our [discord server](https://iplabs.ink/discord).\n\n## Format\n\n### Day 1\n\nThe first day of Low Ink uses the Swiss format. All teams in the tournament will be guaranteed 6 rounds versus other teams. There is no elimination on day 1! You will always get 6 rounds, no matter what your results are. Swiss matches you to against teams with similar win/loss ratios, meaning opponents should get closer to your skill as the day progresses! When day 1 ends, teams progressing to the day 2 elimination brackets will be announced in the li-announcements channel on our [discord server](https://iplabs.ink/discord).\n\n### Day 2\n\nThe second day of Low Ink consists of individual brackets, for the top teams based on day 1 results. Typically, 1st-8th plays in Alpha bracket, 9th-16th in Beta bracket, and 17th-24th in Gamma bracket. Each of these brackets are straight double elimination. Double elimination means each progressing through the bracket and has to lose two matches to be eliminated. The first loss puts a team in 'losers bracket' and a second ends their tournament run.\n\nDay 2 also utilizes counter picking in the map list. Please read the rules for details on how counterpicking works.\n\n*Format is subject to change if > 128 teams register.*", startTime: 1734199200, organization: { id: 3, name: "Inkling Performance Labs", slug: "inkling-performance-labs", logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", + series: [], members: [ { userId: 405, @@ -7258,6 +7256,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733157607, activeRosterUserIds: [25875, 21063, 11226, 31597], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7368,6 +7367,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733157629, activeRosterUserIds: [14837, 27260, 42704, 9379], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7478,6 +7478,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733161494, activeRosterUserIds: [34424, 31195, 31395, 26103], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7588,6 +7589,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733166918, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7682,6 +7684,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733166213, activeRosterUserIds: [32160, 29267, 25591, 36962], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7787,6 +7790,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733189945, activeRosterUserIds: [12418, 34355, 2319, 7430], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7897,6 +7901,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733244862, activeRosterUserIds: [29425, 31524, 35674, 26285], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8007,6 +8012,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733282085, activeRosterUserIds: [26747, 27292, 5708, 6309], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8133,6 +8139,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733291438, activeRosterUserIds: [24459, 40851, 23974, 43608], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8259,6 +8266,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733439755, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8353,6 +8361,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733485884, activeRosterUserIds: [30686, 1961, 30685, 22396], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8479,6 +8488,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733937993, activeRosterUserIds: [12434, 30263, 5861, 24275], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8605,6 +8615,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733166818, activeRosterUserIds: [32670, 38046, 42638, 34589], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-Hj-Us_Roj5Ksfv000ceBo-1733166818832.webp", members: [ { @@ -8715,6 +8726,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733167616, activeRosterUserIds: [45102, 26711, 41739, 4533], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8825,6 +8837,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733201503, activeRosterUserIds: [20807, 31556, 33373, 42703], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8951,6 +8964,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733218069, activeRosterUserIds: [26509, 7959, 7690, 7958], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9061,6 +9075,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733319202, activeRosterUserIds: [10714, 21685, 8840, 10028], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9187,6 +9202,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733471556, activeRosterUserIds: [17532, 30204, 36007, 38896], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9297,6 +9313,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733501938, activeRosterUserIds: [30495, 43073, 30488, 45295], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9407,6 +9424,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733622364, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9495,6 +9513,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733635706, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9589,6 +9608,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733671856, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9678,6 +9698,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733810204, activeRosterUserIds: [1959, 17352, 33954, 22403], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-3KZntw8OZ9LkW4XqZRLS9-1733810204048.webp", members: [ { @@ -9783,6 +9804,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733889961, activeRosterUserIds: [6696, 32107, 33402, 30619], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9888,6 +9910,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733892132, activeRosterUserIds: [21670, 8993, 8395, 3566], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -9998,6 +10021,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734035170, activeRosterUserIds: [24510, 10670, 22577, 31143], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10108,6 +10132,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734107844, activeRosterUserIds: [28170, 14309, 17310, 23164], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10218,6 +10243,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734132225, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-_asHjlVchhJ50PH_mDBtw-1734132224819.webp", members: [ { @@ -10307,6 +10333,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733194304, activeRosterUserIds: [40505, 29011, 23082, 45036], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10417,6 +10444,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733195091, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10511,6 +10539,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733364647, activeRosterUserIds: [22801, 31150, 35354, 27747], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10621,6 +10650,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733374295, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10715,6 +10745,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733433864, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10835,6 +10866,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733513814, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -10929,6 +10961,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733602400, activeRosterUserIds: [10826, 4248, 20419, 11180], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11039,6 +11072,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733753214, activeRosterUserIds: [27903, 28446, 34634, 30728], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11149,6 +11183,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733914001, activeRosterUserIds: [32909, 10190, 35922, 40304], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11275,6 +11310,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733966548, activeRosterUserIds: [35617, 37669, 37436, 35811], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11385,6 +11421,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734032213, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11479,6 +11516,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734106606, activeRosterUserIds: [37173, 43269, 43623, 16054], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11589,6 +11627,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734116765, activeRosterUserIds: [25312, 10378, 46771, 26044], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11715,6 +11754,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734125312, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11809,6 +11849,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734134382, activeRosterUserIds: [26758, 25689, 42164, 44475], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -11914,6 +11955,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733156802, activeRosterUserIds: [9036, 7434, 3738, 9112], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12024,6 +12066,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733157391, activeRosterUserIds: [5935, 38204, 3741, 8080], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12150,6 +12193,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733162274, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12244,6 +12288,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733367806, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12338,6 +12383,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733456080, activeRosterUserIds: [10386, 33369, 29617, 22942], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12448,6 +12494,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733579092, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12536,6 +12583,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733769667, activeRosterUserIds: [3481, 38022, 41269, 43551], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12662,6 +12710,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733794148, activeRosterUserIds: [22820, 29636, 27036, 28959], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12788,6 +12837,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733820540, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-t2-mrQNINFqIoFNYuxbmW-1733820600291.webp", members: [ { @@ -12877,6 +12927,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733825084, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -12971,6 +13022,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733865890, activeRosterUserIds: [15425, 41975, 28938, 8587], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13081,6 +13133,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733873149, activeRosterUserIds: [40550, 7115, 29674, 30031], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13207,6 +13260,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733875608, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13301,6 +13355,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733888417, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-c9a1igcMT4m2otyRdTs_0-1733888672873.webp", members: [ { @@ -13390,6 +13445,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734008857, activeRosterUserIds: [30266, 37341, 22699, 28145], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13500,6 +13556,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734018352, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13594,6 +13651,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734019701, activeRosterUserIds: [35421, 33524, 22500, 32802], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-u4oKxXYjamTXZ1x-bgNFp-1734019701188.webp", members: [ { @@ -13715,6 +13773,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734023441, activeRosterUserIds: [1852, 2898, 25763, 3466], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13825,6 +13884,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734099744, activeRosterUserIds: [39098, 22624, 28137, 2769], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -13935,6 +13995,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734109256, activeRosterUserIds: [29661, 15158, 35067, 31655], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-An13SrR78qDNIM2t95ujb-1734109256283.webp", members: [ { @@ -14056,6 +14117,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734125682, activeRosterUserIds: [36575, 30425, 32430, 24290], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14182,6 +14244,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733515005, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14276,6 +14339,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733521735, activeRosterUserIds: [44772, 38912, 36853, 42599], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14386,6 +14450,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733525617, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14480,6 +14545,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733847805, activeRosterUserIds: [33615, 32015, 45778, 32970], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14590,6 +14656,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733858126, activeRosterUserIds: [34545, 35567, 41108, 41255], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14700,6 +14767,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733966096, activeRosterUserIds: [39470, 42874, 32878, 25741], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -14810,6 +14878,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734021147, activeRosterUserIds: [45250, 45174, 6976, 10222], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-v3boyVjbFsTyMlQylz4Dn-1734021152539.webp", members: [ { @@ -14920,6 +14989,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734040772, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-Jx6JnhFJQjOnM10s_79ld-1734041234919.webp", members: [ { @@ -15014,6 +15084,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734033803, activeRosterUserIds: [27800, 12235, 30044, 29531], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15124,6 +15195,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734099612, activeRosterUserIds: [24572, 7058, 37641, 33913], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-RrPQW5kG_K1cvjdU5TKcF-1734099611923.webp", members: [ { @@ -15229,6 +15301,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734113463, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15323,6 +15396,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734118202, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15411,6 +15485,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734134334, activeRosterUserIds: [11186, 27611, 25952, 23481], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15521,6 +15596,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733169181, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15615,6 +15691,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733247691, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo--fZF6IGlzuuHeotc6Z00p-1733762912138.webp", members: [ { @@ -15709,6 +15786,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733452618, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15808,6 +15886,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733481710, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -15912,6 +15991,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733508949, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -16006,6 +16086,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733611261, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -16095,6 +16176,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733841846, activeRosterUserIds: [41943, 46289, 45290, 46394], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -16205,6 +16287,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1733878153, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -16299,6 +16382,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ abDivision: null, createdAt: 1734135144, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-obQfxdRnJg0CsbrE6OXdl-1734135144301.webp", members: [ { diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 8004c0919..978af01bd 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -2018,17 +2018,15 @@ export const SWIM_OR_SINK_167 = ( }, seedingSnapshot: null, mapPickingStyle: "TO", - rules: - "Here are our rules in a Google Doc!\n\nhttps://docs.google.com/document/d/1Q92U2lKmm337Xi0RpHSFS3RYdSw9Ivn8BC-Be0-J3r8/", + hasRules: true, name: "Swim or Sink 167", - description: - "IPL's weekly open-level tournament! // No entry limit! // Every team makes a bracket!", startTime: 1730941200, organization: { id: 3, name: "Inkling Performance Labs", slug: "inkling-performance-labs", logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp", + series: [], members: [ { userId: 405, @@ -2368,6 +2366,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730771673, activeRosterUserIds: [8852, 34724, 9403, 27222], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -2509,6 +2508,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730931681, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -2618,6 +2618,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730864603, activeRosterUserIds: [22344, 1038, 1059, 10200], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -2743,6 +2744,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730932511, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-gYdQMQEToU9InodFy5P0z-1730936760761.webp", members: [ { @@ -2852,6 +2854,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730922495, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-EwC2mgMiWfx54bSWSPhr3-1730922495444.webp", members: [ { @@ -2961,6 +2964,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730788095, activeRosterUserIds: [7807, 11815, 5001, 7216], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3107,6 +3111,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730774561, activeRosterUserIds: [3657, 5227, 25622, 25053], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3237,6 +3242,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730935243, activeRosterUserIds: [73, 8760, 1548, 163], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3367,6 +3373,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730851309, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3481,6 +3488,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730870818, activeRosterUserIds: [22614, 11244, 3181, 11495], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3606,6 +3614,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730872875, activeRosterUserIds: [21487, 28391, 23292, 13854], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-b-J9byWINvtDmLv24gzeJ-1730872875114.webp", members: [ { @@ -3731,6 +3740,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730934390, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3840,6 +3850,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730925172, activeRosterUserIds: [863, 34414, 27917, 15278], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -3970,6 +3981,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730526186, activeRosterUserIds: [1736, 986, 25464, 2300], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -4116,6 +4128,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730940438, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-nETHLw8QQ0_AzfiPsuBwQ-1730940553743.webp", members: [ { @@ -4183,6 +4196,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730912708, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-bXW8Qb1mvrgvqCr0J-cq7-1730912708301.webp", members: [ { @@ -4292,6 +4306,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730936919, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-_wB0IydnUgDBkskX8eq35-1730936919244.webp", members: [ { @@ -4401,6 +4416,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730937337, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-jdoLt7-S9ClBnQNRtqOze-1730937337828.webp", members: [ { @@ -4510,6 +4526,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730769135, activeRosterUserIds: [33116, 34014, 44751, 22756], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-ItBKhtBOvsxcD9yMJDrsP-1730769135503.webp", members: [ { @@ -4656,6 +4673,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730836875, activeRosterUserIds: [1616, 17310, 34657, 22409], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -4786,6 +4804,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730844165, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -4900,6 +4919,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730926359, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-p3UgTkOwvFWBT2Cxvuhuq-1730926358900.webp", members: [ { @@ -5009,6 +5029,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730928135, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5118,6 +5139,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730880730, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5222,6 +5244,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730939363, activeRosterUserIds: [21205, 1953, 32885, 2888], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5352,6 +5375,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730838132, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5466,6 +5490,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730917380, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5522,6 +5547,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730832667, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5631,6 +5657,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730853887, activeRosterUserIds: [29120, 35225, 8587, 27440], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -5761,6 +5788,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730928986, activeRosterUserIds: [26103, 31395, 33402, 31195], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-tEtTINjP6iHk01HpA1rQ2-1730928986789.webp", members: [ { @@ -5886,6 +5914,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730775625, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6000,6 +6029,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730605037, activeRosterUserIds: [24514, 5187, 29823, 22744], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6130,6 +6160,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730862741, activeRosterUserIds: [5584, 30612, 13671, 36898], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6260,6 +6291,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730840221, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6364,6 +6396,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730770163, activeRosterUserIds: [25469, 3513, 26820, 30122], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6510,6 +6543,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730753582, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6598,6 +6632,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730929508, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6712,6 +6747,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730918770, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6795,6 +6831,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730851475, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -6904,6 +6941,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730726309, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7008,6 +7046,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730840643, activeRosterUserIds: [41797, 29855, 34594, 26801], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7133,6 +7172,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730827259, activeRosterUserIds: [25218, 26988, 26989, 12610], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7263,6 +7303,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730863477, activeRosterUserIds: [37341, 30266, 22699, 39363], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7409,6 +7450,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730907528, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-CsXMAg2pGXBRBfZeRBBqa-1730907542690.webp", members: [ { @@ -7518,6 +7560,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730932702, activeRosterUserIds: [45980, 45163, 32203, 46101], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7643,6 +7686,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730832003, activeRosterUserIds: [40505, 29011, 23082, 33067], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7773,6 +7817,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730938507, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -7882,6 +7927,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730739211, activeRosterUserIds: [23712, 8080, 7994, 20990], + avatarImgId: null, pickupAvatarUrl: "pickup-logo-PntW-hxfVpRHVnxBML2g2-1730739211607.webp", members: [ { @@ -8023,6 +8069,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730857328, activeRosterUserIds: [29531, 3275, 35169, 7008], + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8153,6 +8200,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730591675, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -8241,6 +8289,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730859986, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-wYiwNATx-T8R_RztGHumf-1730859986110.webp", members: [ { @@ -8350,6 +8399,7 @@ export const SWIM_OR_SINK_167 = ( inviteCode: null, createdAt: 1730703689, activeRosterUserIds: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index 5da540050..928485c0c 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -321,12 +321,9 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ castedMatchesInfo: null, seedingSnapshot: null, mapPickingStyle: "TO", - rules: - "For the complete and up to date rules see #rules and #announcements in the discord.\n\n**Tournament Proceedings**\nContact your opponent through tournament match page. If issues occur, a TO may direct you to a captain’s chat in the discord.\n\n**Map Counterpicks**\nThe loser of each match chooses the next map in the round. A team may not choose a map that has already been played in the set.\n\n**Disconnections**\nEach team can replay once per set when a disconnection occurs on their side if both of the following apply: \n- the disconnection occurs before 2:30 on the match timer.\n- the objective counter of the team without the disconnect is above 40.\nIf a disconnection occurs before 30 seconds into the match then a free replay is given. Please avoid replaying when these conditions aren’t met (i.e. gentlemen’s replay) so to keep the tournament running on time.\n\n**Other Rules**\n- Use of the private battle quit feature for malicious purposes will result in disqualification.\n- Penalties may be issued to teams that are not in the match lobby within 10 minutes of round start.\n\n**Player Restrictions**\nEach team is allowed up to 6 players. Players of the following group are not allowed to participate without specific exemption from Puma\n- Non-OCE players\n- Oceanink banned players\n\n-- Tournament Organisers reserve the right to make last minute changes to the rules —", + hasRules: true, parentTournamentId: null, name: "Zones Weekly 38", - description: - "A short and sweet, weekly zones only tournament for the OCE and SEA region. Format is 4 rounds of Bo5 Swiss with counterpicks.\n\nJoin the discord for more info.", startTime: 1734685200, isFinalized: 0, organization: null, @@ -390,6 +387,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: [5662, 2899, 6114, 30176], startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -500,6 +498,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: null, startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-rZYQMu8ELjiFkeiAVGJUt-1734424882431.webp", members: [ { @@ -589,6 +588,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: null, startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -678,6 +678,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: [37632, 13590, 10757, 33047], startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: null, members: [ { @@ -788,6 +789,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: [11780, 46006, 43518, 33483], startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-FOfFcEbo2OJxIJIJxNJqu-1734608907317.webp", members: [ { @@ -909,6 +911,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: [46467, 46813, 33491, 43662], startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-y79k_HOVmjv4KfhTjuSqh-1734398099266.webp", members: [ { @@ -1019,6 +1022,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ activeRosterUserIds: null, startingBracketIdx: null, abDivision: null, + avatarImgId: null, pickupAvatarUrl: "pickup-logo-IGXFtjFMa_dxQqAe2dqIR-1734598652684.webp", members: [ { diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index eacb0cad1..22eaa6cb7 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1456,9 +1456,7 @@ export const PADDLING_POOL_257 = () => seedingSnapshot: null, mapPickingStyle: "AUTO_ALL", name: "Paddling Pool 257", - description: - "Hosted by Dapple Productions.\n\nThe longest tournament series in Splatoon!\nEvery week a tournament!\n\n✓ DE or Groups into SE\n✓ All Modes (Picnic system)\n✓ Badge prize\n✓ A well-ran tournament experience\n\nCome join!", - rules: null, + hasRules: false, logoUrl: "/test.avif", startTime: 1709748000, author: { @@ -1529,6 +1527,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709743534, members: [ @@ -1656,6 +1655,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709737918, members: [ @@ -1799,6 +1799,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709743523, members: [ @@ -1926,6 +1927,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709743262, members: [ @@ -2053,6 +2055,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709741396, members: [ @@ -2196,6 +2199,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709711811, members: [ @@ -2339,6 +2343,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709738831, members: [ @@ -2482,6 +2487,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709737837, members: [ @@ -2609,6 +2615,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709741719, members: [ @@ -2768,6 +2775,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709730354, members: [ @@ -2911,6 +2919,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709745630, members: [ @@ -3052,6 +3061,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709592381, members: [ @@ -3211,6 +3221,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709723749, members: [ @@ -3354,6 +3365,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709668399, members: [ @@ -3513,6 +3525,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709735267, members: [ @@ -3645,6 +3658,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709745849, members: [ @@ -3777,6 +3791,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709742258, members: [ @@ -3918,6 +3933,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709738744, members: [ @@ -4061,6 +4077,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709746054, members: [ @@ -4191,6 +4208,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709744894, members: [ @@ -4318,6 +4336,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709728278, members: [ @@ -4445,6 +4464,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709715006, members: [ @@ -4572,6 +4592,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709660578, members: [ @@ -4704,6 +4725,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709721869, members: [ @@ -4850,6 +4872,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709743633, members: [ @@ -4982,6 +5005,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709738747, members: [ @@ -5130,6 +5154,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709626047, members: [ @@ -5273,6 +5298,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709727951, members: [ @@ -5400,6 +5426,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709741482, members: [ @@ -5557,6 +5584,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709744451, members: [ @@ -5689,6 +5717,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709726536, members: [ @@ -5816,6 +5845,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709558706, members: [ @@ -5948,6 +5978,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709744323, members: [ @@ -6096,6 +6127,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709677397, members: [ @@ -6223,6 +6255,7 @@ export const PADDLING_POOL_257 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1709618711, members: [ @@ -8085,8 +8118,7 @@ export const PADDLING_POOL_255 = () => seedingSnapshot: null, mapPickingStyle: "AUTO_ALL", name: "Paddling Pool 255", - description: null, - rules: null, + hasRules: false, logoUrl: "/test.avif", startTime: 1708538400, author: { @@ -8155,6 +8187,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708476597, members: [ @@ -8282,6 +8315,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708535137, members: [ @@ -8409,6 +8443,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708533764, members: [ @@ -8550,6 +8585,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708537512, members: [ @@ -8693,6 +8729,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708533309, members: [ @@ -8820,6 +8857,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708430641, members: [ @@ -8963,6 +9001,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708536306, members: [ @@ -9088,6 +9127,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708526368, members: [ @@ -9215,6 +9255,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708506060, members: [ @@ -9374,6 +9415,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708526814, members: [ @@ -9499,6 +9541,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708466421, members: [ @@ -9642,6 +9685,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708377426, members: [ @@ -9783,6 +9827,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708448289, members: [ @@ -9942,6 +9987,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708532602, members: [ @@ -10069,6 +10115,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708535205, members: [ @@ -10212,6 +10259,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708515945, members: [ @@ -10339,6 +10387,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708453334, members: [ @@ -10464,6 +10513,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708522730, members: [ @@ -10607,6 +10657,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708375443, members: [ @@ -10750,6 +10801,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708532665, members: [ @@ -10882,6 +10934,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708364254, members: [ @@ -11030,6 +11083,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708464101, members: [ @@ -11176,6 +11230,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708520249, members: [ @@ -11319,6 +11374,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708535804, members: [ @@ -11446,6 +11502,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708535891, members: [ @@ -11589,6 +11646,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708521749, members: [ @@ -11732,6 +11790,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708536584, members: [ @@ -11859,6 +11918,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708537772, members: [ @@ -12023,6 +12083,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708379916, members: [ @@ -12166,6 +12227,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708519753, members: [ @@ -12314,6 +12376,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708534312, members: [ @@ -12460,6 +12523,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708531929, members: [ @@ -12587,6 +12651,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708477155, members: [ @@ -12719,6 +12784,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708531564, members: [ @@ -12899,6 +12965,7 @@ export const PADDLING_POOL_255 = () => startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1708503356, members: [ @@ -15039,8 +15106,7 @@ export const IN_THE_ZONE_32 = ({ seedingSnapshot: null, mapPickingStyle: "AUTO_SZ", name: "In The Zone 32", - description: "Part of sendou.ink ranked season 2", - rules: null, + hasRules: false, logoUrl: "/test.avif", startTime: 1707588000, author: { @@ -15093,6 +15159,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707443313, members: [ @@ -15207,6 +15274,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707366405, members: [ @@ -15321,6 +15389,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1706912643, members: [ @@ -15435,6 +15504,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707359335, members: [ @@ -15581,6 +15651,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707171426, members: [ @@ -15711,6 +15782,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707342696, members: [ @@ -15841,6 +15913,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707513942, members: [ @@ -15987,6 +16060,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707526815, members: [ @@ -16133,6 +16207,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707583385, members: [ @@ -16247,6 +16322,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707486395, members: [ @@ -16361,6 +16437,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707513290, members: [ @@ -16475,6 +16552,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707531084, members: [ @@ -16589,6 +16667,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707568466, members: [ @@ -16719,6 +16798,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707481625, members: [ @@ -16833,6 +16913,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707530166, members: [ @@ -16947,6 +17028,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707181792, members: [ @@ -17077,6 +17159,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707550321, members: [ @@ -17212,6 +17295,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707575096, members: [ @@ -17342,6 +17426,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707569490, members: [ @@ -17488,6 +17573,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707537425, members: [ @@ -17602,6 +17688,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707564691, members: [ @@ -17753,6 +17840,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707145818, members: [ @@ -17877,6 +17965,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707558330, members: [ @@ -17991,6 +18080,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707586842, members: [ @@ -18105,6 +18195,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707583597, members: [ @@ -18251,6 +18342,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707429804, members: [ @@ -18381,6 +18473,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707539973, members: [ @@ -18516,6 +18609,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707507831, members: [ @@ -18646,6 +18740,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707586297, members: [ @@ -18774,6 +18869,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707583885, members: [ @@ -18920,6 +19016,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707578076, members: [ @@ -19053,6 +19150,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707582953, members: [ @@ -19167,6 +19265,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, createdAt: 1707575330, members: [ @@ -19304,6 +19403,7 @@ export const IN_THE_ZONE_32 = ({ startingBracketIdx: null, abDivision: null, activeRosterUserIds: [], + avatarImgId: null, 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 05afc0675..54316c09d 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -20,6 +20,7 @@ export const tournamentCtxTeam = ( mapPool: [], members: [], activeRosterUserIds: [], + avatarImgId: null, pickupAvatarUrl: null, name: `Team ${teamId}`, prefersNotToHost: 0, @@ -62,12 +63,11 @@ export const testTournament = ({ eventId: 1, id: 1, tags: null, - description: null, organization: null, tier: null, tentativeTier: null, parentTournamentId: null, - rules: null, + hasRules: false, logoUrl: "/test.avif", discordUrl: null, startTime: 1705858842, diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 4bcd36a02..0204da11a 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -7,6 +7,7 @@ import { ShieldMinus, ShieldPlus, Stamp, + UserPlus, } from "lucide-react"; import * as React from "react"; import { ErrorBoundary } from "react-error-boundary"; @@ -30,6 +31,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { useHydrated } from "~/hooks/useHydrated"; import { useSearchParamState } from "~/hooks/useSearchParamState"; import { useVisibilityChange } from "~/hooks/useVisibilityChange"; +import type { SendouRouteHandle } from "~/utils/remix.server"; import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls"; import { useBracketExpanded, @@ -49,6 +51,10 @@ import { tournamentWebsocketRoom } from "../tournament-bracket-utils"; export { action }; +export const handle: SendouRouteHandle = { + mainBreakout: true, +}; + import styles from "../tournament-bracket.module.css"; export default function TournamentBracketsPage() { @@ -57,7 +63,6 @@ export default function TournamentBracketsPage() { const { revalidate } = useRevalidator(); const user = useUser(); const tournament = useTournament(); - const isHydrated = useHydrated(); const ctx = useOutletContext(); const defaultBracketIdx = () => { @@ -108,11 +113,11 @@ export default function TournamentBracketsPage() { hide: hideSpoiler, } = useBracketSpoilerCensor(); - const showPrepareMapsButton = - tournament.isOrganizer(user) && - !bracket.canBeStarted && - bracket.preview && - isHydrated; + const showTeamActionsRow = + (!tournament.isLeagueDivision && Boolean(teamProgressStatus)) || + showAddSubsButton; + const showSecondaryActionsRow = + tournament.canFinalize(user) || censored || canToggle; const waitingForTeamsText = (bracket: BracketType, bracketIdx: number) => { if (bracketIdx > 0) { @@ -178,117 +183,42 @@ export default function TournamentBracketsPage() { return null; }; - const totalTeamsAvailableForTheBracket = () => { - if (bracket.sources) { - return ( - (bracket.teamsPendingCheckIn ?? []).length + - bracket.participantTournamentTeamIds.length - ); - } - - if (!tournament.isMultiStartingBracket) { - return tournament.ctx.teams.length; - } - - return tournament.ctx.teams.filter( - (team) => (team.startingBracketIdx ?? 0) === bracketIdx, - ).length; - }; - if (tournament.isLeagueSignup) { return null; } - const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament); - return (
- {bracket.preview && - tournament.isOrganizer(user) && - tournament.regularCheckInHasEnded && - abDivisionsStartError ? ( -
- -
- {abDivisionsStartError} -
-
+ {showTeamActionsRow ? ( +
+ {/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */} + {!tournament.isLeagueDivision ? : null} + {showAddSubsButton ? : null}
) : null} - {bracket.preview && - bracket.enoughTeams && - tournament.isOrganizer(user) && - tournament.regularCheckInStartInThePast ? ( -
-
- + {tournament.canFinalize(user) ? ( + } > - {bracket.participantTournamentTeamIds.length}/ - {totalTeamsAvailableForTheBracket()} teams checked in - {bracket.canBeStarted ? ( - tournament.isDraft ? ( - - ) : ( - - ) - ) : null} - - {!bracket.canBeStarted ? ( -
- ⚠️{" "} - {bracket.isStartingBracket - ? "Tournament start time is in the future" - : bracket.startTime && bracket.startTime > new Date() - ? "Bracket start time is in the future" - : "Teams pending from the previous bracket"}{" "} - (blocks starting) -
- ) : null} -
+ {t("tournament:actions.finalize.button")} + + ) : null} + {censored ? ( + }> + {t("common:spoilerFree.showResults")} + + ) : canToggle ? ( + }> + {t("common:spoilerFree.hideResults")} + + ) : null}
) : null} -
- {/** TournamentTeamActions more confusing than helpful for leagues, for example might say "Waiting for match..." when previous match was rescheduled */} - {!tournament.isLeagueDivision ? : null} - {showAddSubsButton ? : null} -
-
- {bracket.type !== "round_robin" && !bracket.preview ? ( - - ) : null} - {tournament.canFinalize(user) ? ( - } - > - {t("tournament:actions.finalize.button")} - - ) : null} - {censored ? ( - }> - {t("common:spoilerFree.showResults")} - - ) : canToggle ? ( - }> - {t("common:spoilerFree.hideResults")} - - ) : null} - {showPrepareMapsButton ? ( - // Error Boundary because preparing maps is optional, so no need to make the whole page inaccessible if it fails - - - - ) : null} -
{(currentBracket, currentBracketIdx) => ( (team.startingBracketIdx ?? 0) === bracketIdx, + ).length; +} + +function bracketTabTeamCount( + tournament: Tournament, + bracket: BracketType, + bracketIdx: number, +) { + return bracket.preview + ? eligibleTeamCountForBracket(tournament, bracket, bracketIdx) + : bracket.participantTournamentTeamIds.length; +} + function getAbDivisionsStartError( bracket: BracketType, tournament: Tournament, @@ -473,7 +434,7 @@ function AddSubsPopOver() { <>
{t("tournament:actions.shareLink", { inviteLink })}
-
+
copyToClipboard(inviteLink)} @@ -501,6 +462,7 @@ function SubsPopover({ children }: { children: React.ReactNode }) { className="ml-auto" variant="outlined" size="small" + icon={} data-testid="add-sub-button" > {t("tournament:actions.addSub")} @@ -538,7 +500,15 @@ function BracketTabs({ > {visibleBrackets.map((bracket, i) => ( - + {bracketNameForTab(bracket.name)} ))} @@ -565,8 +535,18 @@ function BracketTabContent({ }) { return ( <> + + {bracket.enoughTeams ? ( - + <> + {bracket.type !== "round_robin" && !bracket.preview ? ( +
+ +
+ ) : null} + + + ) : (
@@ -604,6 +584,127 @@ function BracketTabContent({ ); } +function PrepareMapsButton({ + bracket, + bracketIdx, +}: { + bracket: BracketType; + bracketIdx: number; +}) { + const tournament = useTournament(); + const user = useUser(); + const isHydrated = useHydrated(); + + if ( + !tournament.isOrganizer(user) || + bracket.canBeStarted || + !bracket.preview || + !isHydrated + ) { + return null; + } + + return ( +
+ {/* Error Boundary because preparing maps is optional, so no need to make the whole page inaccessible if it fails */} + + + +
+ ); +} + +function AbDivisionsImbalanceAlert({ bracket }: { bracket: BracketType }) { + const tournament = useTournament(); + const user = useUser(); + + if ( + !bracket.preview || + !tournament.isOrganizer(user) || + !tournament.regularCheckInHasEnded + ) { + return null; + } + + const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament); + if (!abDivisionsStartError) { + return null; + } + + return ( +
+ +
+ {abDivisionsStartError} +
+
+
+ ); +} + +function StartBracketAlert({ + bracket, + bracketIdx, +}: { + bracket: BracketType; + bracketIdx: number; +}) { + const tournament = useTournament(); + const user = useUser(); + + if ( + !bracket.preview || + !tournament.isOrganizer(user) || + !tournament.regularCheckInStartInThePast + ) { + return null; + } + + const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament); + const totalTeamsAvailableForTheBracket = eligibleTeamCountForBracket( + tournament, + bracket, + bracketIdx, + ); + + return ( +
+
+ + {bracket.participantTournamentTeamIds.length}/ + {totalTeamsAvailableForTheBracket} teams checked in + {bracket.canBeStarted ? ( + tournament.isDraft ? ( + + ) : ( + + ) + ) : null} + + {!bracket.canBeStarted ? ( +
+ ⚠️{" "} + {bracket.isStartingBracket + ? "Tournament start time is in the future" + : bracket.startTime && bracket.startTime > new Date() + ? "Bracket start time is in the future" + : "Teams pending from the previous bracket"}{" "} + (blocks starting) +
+ ) : null} +
+
+ ); +} + function CompactifyButton() { const { bracketExpanded, setBracketExpanded } = useBracketExpanded(); diff --git a/app/features/tournament-lfg/routes/to.$id.looking.tsx b/app/features/tournament-lfg/routes/to.$id.looking.tsx index 17ec6e276..8791a61a0 100644 --- a/app/features/tournament-lfg/routes/to.$id.looking.tsx +++ b/app/features/tournament-lfg/routes/to.$id.looking.tsx @@ -234,6 +234,7 @@ function SubsView({ }: { data: Extract; }) { + const { t } = useTranslation(["tournament"]); const user = useUser(); const tournament = useTournament(); @@ -247,9 +248,13 @@ function SubsView({ !isOnTeam ? ( ) : null} - {data.subs.map((sub) => ( - - ))} + {data.subs.length > 0 ? ( + data.subs.map((sub) => ) + ) : ( +
+ {t("tournament:subs.noPosts")} +
+ )}
); } diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts index 368e7e97c..3e9311a00 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -248,7 +248,7 @@ export async function allResultsByTournamentId( ).as("maps"), ]) .where("TournamentStage.tournamentId", "=", tournamentId) - .where(opponentOneResult, "is not", null) // xxx: "opponentOneId" is not null/"opponentTwoId" is not null? + .where(opponentOneResult, "is not", null) // strictly speaking the order by condition is not accurate, future improvement would be to add order conditions that match the tournament structure .orderBy("TournamentMatch.id", "asc") .execute(); diff --git a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts index 3ad2b1bca..399275df3 100644 --- a/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts +++ b/app/features/tournament-match/routes/to.$id.matches.$mid.test.ts @@ -6,8 +6,9 @@ vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ setMetadata: vi.fn(), })); +import type { z } from "zod"; import { db } from "~/db/sql"; -import type { adminActionSchema } from "~/features/tournament/tournament-schemas.server"; +import { action as removeMemberApiAction } from "~/features/api-public/routes/tournament.$id.teams.$teamId.remove-member"; import { dbInsertTournament, dbInsertTournamentTeam, @@ -22,15 +23,16 @@ import { wrappedAction, wrappedLoader, } from "~/utils/Test"; -import { action as adminAction } from "../../tournament/routes/to.$id.admin"; import { action, loader } from "./to.$id.matches.$mid"; const tournamentMatchAction = wrappedAction({ action, isJsonSubmission: true, }); -const tournamentAdminAction = wrappedAction({ - action: adminAction, +const removeMemberApiActionWrapped = wrappedAction< + z.ZodType<{ userId: number }> +>({ + action: removeMemberApiAction, isJsonSubmission: true, }); @@ -78,13 +80,9 @@ const removeMemberAction = ({ userId: number; teamId: number; }) => - tournamentAdminAction( - { - _action: "REMOVE_MEMBER", - memberId: userId, - teamId, - }, - { user: "admin", params: { id: "1" } }, + removeMemberApiActionWrapped( + { userId }, + { user: "admin", params: { id: "1", teamId: String(teamId) } }, ); describe("Tournament match page", () => { diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts index 7d8af7780..f65eb7e8d 100644 --- a/app/features/tournament-organization/tournament-organization-schemas.ts +++ b/app/features/tournament-organization/tournament-organization-schemas.ts @@ -88,7 +88,7 @@ export const organizationEditFormSchema = z.object({ export const banUserActionSchema = z.object({ _action: stringConstant("BAN_USER"), - userId: userSearch({ label: "labels.banUserPlayer" }), + userId: userSearch({ label: "labels.player" }), privateNote: textAreaOptional({ label: "labels.banUserNote", bottomText: "bottomTexts.banUserNoteHelp", diff --git a/app/features/tournament/TournamentAuditLogRepository.server.test.ts b/app/features/tournament/TournamentAuditLogRepository.server.test.ts new file mode 100644 index 000000000..698b70494 --- /dev/null +++ b/app/features/tournament/TournamentAuditLogRepository.server.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import type { Tables, TournamentAuditLogMetadata } from "~/db/tables"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; +import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; + +const createTournament = () => + db + .insertInto("Tournament") + .values({ + mapPickingStyle: "TO", + settings: JSON.stringify({ bracketProgression: [] }), + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const createTeam = (tournamentId: number, name: string) => + db + .insertInto("TournamentTeam") + .values({ + tournamentId, + name, + inviteCode: `inv-${tournamentId}-${name}`, + }) + .returning("id") + .executeTakeFirstOrThrow(); + +const insertEvent = ({ + actorUserId, + ...args +}: { + type: Tables["TournamentAuditLog"]["type"]; + actorUserId: number; + tournamentTeamId: number; + subjectUserId?: number; + metadata?: TournamentAuditLogMetadata; +}) => + withUserId(actorUserId, () => + db + .transaction() + .execute((trx) => TournamentAuditLogRepository.insert(trx, args)), + ); + +describe("TournamentAuditLogRepository", () => { + beforeEach(async () => { + await dbInsertUsers(3); + }); + + afterEach(() => { + dbReset(); + }); + + test("insert creates a stable history row from the live team", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + const teams = await TournamentAuditLogRepository.findTeamsByTournamentId( + tournament.id, + ); + + expect(teams).toHaveLength(1); + expect(teams[0].tournamentTeamId).toBe(team.id); + expect(teams[0].name).toBe("Team Olive"); + }); + + test("findByTournamentId returns events newest first with resolved relations", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + subjectUserId: 1, + }); + await insertEvent({ + type: "MEMBER_ADDED", + actorUserId: 1, + tournamentTeamId: team.id, + subjectUserId: 2, + }); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events).toHaveLength(2); + // newest first + expect(events[0].type).toBe("MEMBER_ADDED"); + expect(events[0].actor?.id).toBe(1); + expect(events[0].subject?.id).toBe(2); + expect(events[0].team?.name).toBe("Team Olive"); + expect(events[1].type).toBe("TEAM_REGISTERED"); + }); + + test("team name survives the team being deleted", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_UNREGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + await db + .deleteFrom("TournamentTeam") + .where("TournamentTeam.id", "=", team.id) + .execute(); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events).toHaveLength(1); + expect(events[0].team?.name).toBe("Team Olive"); + }); + + test("a reused team id does not collapse two teams into one history", async () => { + const tournament = await createTournament(); + const teamA = await createTeam(tournament.id, "Team A"); + + await insertEvent({ + type: "TEAM_UNREGISTERED", + actorUserId: 1, + tournamentTeamId: teamA.id, + }); + + await db + .deleteFrom("TournamentTeam") + .where("TournamentTeam.id", "=", teamA.id) + .execute(); + + const teamB = await createTeam(tournament.id, "Team B"); + // SQLite reuses the highest deleted rowid for the next insert + expect(teamB.id).toBe(teamA.id); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: teamB.id, + }); + + const teams = await TournamentAuditLogRepository.findTeamsByTournamentId( + tournament.id, + ); + expect(teams).toHaveLength(2); + expect(teams.map((team) => team.name).sort()).toEqual(["Team A", "Team B"]); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + const eventByType = new Map(events.map((event) => [event.type, event])); + expect(eventByType.get("TEAM_UNREGISTERED")?.team?.name).toBe("Team A"); + expect(eventByType.get("TEAM_REGISTERED")?.team?.name).toBe("Team B"); + }); + + test("filters by event type and by team", async () => { + const tournament = await createTournament(); + const teamA = await createTeam(tournament.id, "Team A"); + const teamB = await createTeam(tournament.id, "Team B"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: teamA.id, + }); + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: teamA.id, + }); + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: teamB.id, + }); + + const byType = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + type: "TEAM_REGISTERED", + limit: 30, + offset: 0, + }); + expect(byType).toHaveLength(2); + + const teams = await TournamentAuditLogRepository.findTeamsByTournamentId( + tournament.id, + ); + const teamAHistoryId = teams.find( + (team) => team.tournamentTeamId === teamA.id, + )?.id; + + const byTeam = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + tournamentTeamHistoryId: teamAHistoryId, + limit: 30, + offset: 0, + }); + expect(byTeam).toHaveLength(2); + + const count = await TournamentAuditLogRepository.countByTournamentId({ + tournamentId: tournament.id, + type: "TEAM_REGISTERED", + }); + expect(count).toBe(2); + }); + + test("paginates via limit and offset", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + for (let i = 0; i < 3; i++) { + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: team.id, + }); + } + + const firstPage = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 2, + offset: 0, + }); + const secondPage = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 2, + offset: 2, + }); + + expect(firstPage).toHaveLength(2); + expect(secondPage).toHaveLength(1); + }); + + test("stores and reads back metadata", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "TEAM_CHECKED_IN", + actorUserId: 1, + tournamentTeamId: team.id, + metadata: { bracketIdx: 2 }, + }); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events[0].metadata?.bracketIdx).toBe(2); + }); + + test("stores and reads back the in-game name for name change events", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Team Olive"); + + await insertEvent({ + type: "UPDATE_IN_GAME_NAME", + actorUserId: 1, + tournamentTeamId: team.id, + subjectUserId: 2, + metadata: { inGameName: "New IGN#1234" }, + }); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events[0].type).toBe("UPDATE_IN_GAME_NAME"); + expect(events[0].subject?.id).toBe(2); + expect(events[0].metadata?.inGameName).toBe("New IGN#1234"); + }); + + test("updateTeamHistoryName keeps the preserved name current", async () => { + const tournament = await createTournament(); + const team = await createTeam(tournament.id, "Old Name"); + + await insertEvent({ + type: "TEAM_REGISTERED", + actorUserId: 1, + tournamentTeamId: team.id, + }); + + await db.transaction().execute((trx) => + TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId: team.id, + name: "New Name", + }), + ); + + const events = await TournamentAuditLogRepository.findByTournamentId({ + tournamentId: tournament.id, + limit: 30, + offset: 0, + }); + + expect(events[0].team?.name).toBe("New Name"); + }); +}); diff --git a/app/features/tournament/TournamentAuditLogRepository.server.ts b/app/features/tournament/TournamentAuditLogRepository.server.ts new file mode 100644 index 000000000..a82c1c5f3 --- /dev/null +++ b/app/features/tournament/TournamentAuditLogRepository.server.ts @@ -0,0 +1,240 @@ +import { sub } from "date-fns"; +import type { Transaction } from "kysely"; +import { jsonObjectFrom } from "kysely/helpers/sqlite"; +import { db } from "~/db/sql"; +import type { DB, Tables, TournamentAuditLogMetadata } from "~/db/tables"; +import { actorId } from "~/features/auth/core/user.server"; +import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; +import { COMMON_USER_FIELDS } from "~/utils/kysely.server"; + +export const AUDIT_LOG_PAGE_SIZE = 30; + +type TournamentAuditLogType = Tables["TournamentAuditLog"]["type"]; + +interface InsertArgs { + type: TournamentAuditLogType; + /** The team the event concerns. Its identity is preserved in `TournamentTeamHistory`. */ + tournamentTeamId: number; + /** The affected member, for member-level events. */ + subjectUserId?: number | null; + metadata?: TournamentAuditLogMetadata | null; +} + +/** + * Inserts an audit log event within the caller's transaction (so it commits or + * rolls back atomically with the mutation it records). The acting user is resolved + * from request context via `actorId()`. Ensures a stable `TournamentTeamHistory` + * row exists for the team, so the event remains readable even after the team is + * hard-deleted. + */ +export async function insert(trx: Transaction, args: InsertArgs) { + const team = await trx + .selectFrom("TournamentTeam") + .select([ + "TournamentTeam.tournamentId", + "TournamentTeam.name", + "TournamentTeam.tournamentTeamHistoryId", + ]) + .where("TournamentTeam.id", "=", args.tournamentTeamId) + .executeTakeFirstOrThrow(); + + const tournamentTeamHistoryId = + team.tournamentTeamHistoryId ?? + (await createTeamHistory(trx, { + tournamentTeamId: args.tournamentTeamId, + tournamentId: team.tournamentId, + name: team.name, + })); + + await trx + .insertInto("TournamentAuditLog") + .values({ + tournamentId: team.tournamentId, + type: args.type, + actorUserId: actorId(), + subjectUserId: args.subjectUserId ?? null, + tournamentTeamHistoryId, + metadata: args.metadata ? JSON.stringify(args.metadata) : null, + createdAt: databaseTimestampNow(), + }) + .execute(); +} + +/** + * Creates a fresh history row for a team and links it back from the team, so a + * `TournamentTeam.id` reused by SQLite after a hard-deletion always gets its own + * history row instead of inheriting the deleted team's identity. Returns the new + * history id. + */ +async function createTeamHistory( + trx: Transaction, + { + tournamentTeamId, + tournamentId, + name, + }: { tournamentTeamId: number; tournamentId: number; name: string }, +) { + const { id } = await trx + .insertInto("TournamentTeamHistory") + .values({ tournamentTeamId, tournamentId, name }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .updateTable("TournamentTeam") + .set({ tournamentTeamHistoryId: id }) + .where("TournamentTeam.id", "=", tournamentTeamId) + .execute(); + + return id; +} + +/** + * Keeps the team's preserved name current after a rename. No-op when the team + * has no history row yet (it will be created with the up-to-date name on its + * first audited event). + */ +export function updateTeamHistoryName( + trx: Transaction, + { tournamentTeamId, name }: { tournamentTeamId: number; name: string }, +) { + return trx + .updateTable("TournamentTeamHistory") + .set({ name }) + .where("TournamentTeamHistory.id", "=", (eb) => + eb + .selectFrom("TournamentTeam") + .select("TournamentTeam.tournamentTeamHistoryId") + .where("TournamentTeam.id", "=", tournamentTeamId), + ) + .execute(); +} + +/** + * Returns a page of audit log events for a tournament, newest first, optionally + * filtered by event type and/or team. Resolves the actor, the affected member + * (when present) and the team name (preserved even for deleted teams). + */ +export function findByTournamentId({ + tournamentId, + type, + tournamentTeamHistoryId, + limit, + offset, +}: { + tournamentId: number; + type?: TournamentAuditLogType; + tournamentTeamHistoryId?: number; + limit: number; + offset: number; +}) { + let query = db + .selectFrom("TournamentAuditLog") + .select((eb) => [ + "TournamentAuditLog.id", + "TournamentAuditLog.type", + "TournamentAuditLog.createdAt", + "TournamentAuditLog.metadata", + jsonObjectFrom( + eb + .selectFrom("User") + .select(COMMON_USER_FIELDS) + .whereRef("User.id", "=", "TournamentAuditLog.actorUserId"), + ).as("actor"), + jsonObjectFrom( + eb + .selectFrom("User") + .select(COMMON_USER_FIELDS) + .whereRef("User.id", "=", "TournamentAuditLog.subjectUserId"), + ).as("subject"), + jsonObjectFrom( + eb + .selectFrom("TournamentTeamHistory") + .select([ + "TournamentTeamHistory.id", + "TournamentTeamHistory.tournamentTeamId", + "TournamentTeamHistory.name", + ]) + .whereRef( + "TournamentTeamHistory.id", + "=", + "TournamentAuditLog.tournamentTeamHistoryId", + ), + ).as("team"), + ]) + .where("TournamentAuditLog.tournamentId", "=", tournamentId) + .orderBy("TournamentAuditLog.createdAt", "desc") + .orderBy("TournamentAuditLog.id", "desc") + .limit(limit) + .offset(offset); + + if (type) { + query = query.where("TournamentAuditLog.type", "=", type); + } + if (typeof tournamentTeamHistoryId === "number") { + query = query.where( + "TournamentAuditLog.tournamentTeamHistoryId", + "=", + tournamentTeamHistoryId, + ); + } + + return query.execute(); +} + +/** Counts audit log events for a tournament matching the same optional filters as {@link findByTournamentId}. Used for pagination. */ +export async function countByTournamentId({ + tournamentId, + type, + tournamentTeamHistoryId, +}: { + tournamentId: number; + type?: TournamentAuditLogType; + tournamentTeamHistoryId?: number; +}) { + let query = db + .selectFrom("TournamentAuditLog") + .select((eb) => eb.fn.countAll().as("count")) + .where("TournamentAuditLog.tournamentId", "=", tournamentId); + + if (type) { + query = query.where("TournamentAuditLog.type", "=", type); + } + if (typeof tournamentTeamHistoryId === "number") { + query = query.where( + "TournamentAuditLog.tournamentTeamHistoryId", + "=", + tournamentTeamHistoryId, + ); + } + + const result = await query.executeTakeFirstOrThrow(); + + return result.count; +} + +/** Deletes audit log events older than three months. */ +export function deleteOld() { + return db + .deleteFrom("TournamentAuditLog") + .where( + "createdAt", + "<", + dateToDatabaseTimestamp(sub(new Date(), { months: 3 })), + ) + .executeTakeFirst(); +} + +/** Returns every team (including deleted ones) that has appeared in the tournament's audit log, for the team filter dropdown. */ +export function findTeamsByTournamentId(tournamentId: number) { + return db + .selectFrom("TournamentTeamHistory") + .select([ + "TournamentTeamHistory.id", + "TournamentTeamHistory.tournamentTeamId", + "TournamentTeamHistory.name", + ]) + .where("TournamentTeamHistory.tournamentId", "=", tournamentId) + .orderBy("TournamentTeamHistory.name", "asc") + .execute(); +} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index f42f862cb..32be3cee6 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -56,11 +56,10 @@ export async function findById(id: number) { "Tournament.castTwitchAccounts", "Tournament.castedMatchesInfo", "Tournament.mapPickingStyle", - "Tournament.rules", + sql`"Tournament"."rules" is not null`.as("hasRules"), "Tournament.parentTournamentId", "Tournament.tier", "CalendarEvent.name", - "CalendarEvent.description", "CalendarEventDate.startTime", "Tournament.isFinalized", "Tournament.seedingSnapshot", @@ -99,6 +98,16 @@ export async function findById(id: number) { "TournamentOrganization.id", ), ).as("members"), + jsonArrayFrom( + innerEb + .selectFrom("TournamentOrganizationSeries") + .select("TournamentOrganizationSeries.name") + .whereRef( + "TournamentOrganizationSeries.organizationId", + "=", + "TournamentOrganization.id", + ), + ).as("series"), ]) .whereRef( "TournamentOrganization.id", @@ -157,6 +166,7 @@ export async function findById(id: number) { "TournamentTeam.activeRosterUserIds", "TournamentTeam.startingBracketIdx", "TournamentTeam.abDivision", + "TournamentTeam.avatarImgId", concatUserSubmittedImagePrefix( innerEb.ref("UserSubmittedImage.url"), ).as("pickupAvatarUrl"), @@ -323,6 +333,34 @@ export async function findById(id: number) { }; } +/** + * Loads a tournament's rules markdown. Kept out of {@link findById} since it can + * be large and is only needed on the tournament's rules page. + */ +export async function findRulesById(tournamentId: number) { + const row = await db + .selectFrom("Tournament") + .select("Tournament.rules") + .where("Tournament.id", "=", tournamentId) + .executeTakeFirst(); + + return row?.rules ?? null; +} + +/** + * Loads a tournament's description markdown. Kept out of {@link findById} since it + * can be large and is only needed on the tournament's info page. + */ +export async function findDescriptionById(tournamentId: number) { + const row = await db + .selectFrom("CalendarEvent") + .select("CalendarEvent.description") + .where("CalendarEvent.tournamentId", "=", tournamentId) + .executeTakeFirst(); + + return row?.description ?? null; +} + export async function hasChildTournaments(parentTournamentId: number) { const row = await db .selectFrom("Tournament") @@ -748,37 +786,35 @@ export function overrideTeamBracketProgression({ .execute(); } -export function addStaff({ +export function setStaff({ tournamentId, - userId, - role, + staff, }: { tournamentId: number; - userId: number; - role: Tables["TournamentStaff"]["role"]; + staff: Array<{ + userId: number; + role: Tables["TournamentStaff"]["role"]; + }>; }) { - return db - .insertInto("TournamentStaff") - .values({ - tournamentId, - userId, - role, - }) - .execute(); -} + return db.transaction().execute(async (trx) => { + await trx + .deleteFrom("TournamentStaff") + .where("tournamentId", "=", tournamentId) + .execute(); -export function removeStaff({ - tournamentId, - userId, -}: { - tournamentId: number; - userId: number; -}) { - return db - .deleteFrom("TournamentStaff") - .where("tournamentId", "=", tournamentId) - .where("userId", "=", userId) - .execute(); + if (staff.length > 0) { + await trx + .insertInto("TournamentStaff") + .values( + staff.map((staffer) => ({ + tournamentId, + userId: staffer.userId, + role: staffer.role, + })), + ) + .execute(); + } + }); } interface UpsertPreparedMapsArgs { @@ -827,7 +863,11 @@ export function updateCastTwitchAccounts({ return db .updateTable("Tournament") .set({ - castTwitchAccounts: JSON.stringify(castTwitchAccounts), + castTwitchAccounts: JSON.stringify( + castTwitchAccounts + .map((account) => account.trim().toLowerCase()) + .filter(Boolean), + ), }) .where("id", "=", tournamentId) .execute(); @@ -1277,10 +1317,12 @@ export async function searchByName({ query, limit, minStartTime, + maxStartTime, }: { query: string; limit: number; minStartTime?: Date; + maxStartTime?: Date; }) { let sqlQuery = db .selectFrom("Tournament") @@ -1309,6 +1351,14 @@ export async function searchByName({ ); } + if (maxStartTime) { + sqlQuery = sqlQuery.where( + "CalendarEventDate.startTime", + "<=", + dateToDatabaseTimestamp(maxStartTime), + ); + } + return sqlQuery.execute(); } diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 72191bfbf..e3566c561 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -9,6 +9,7 @@ import { flatZip } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; +import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; export function setActiveRoster({ teamId, @@ -58,7 +59,7 @@ const regOpenTournamentTeamsByJoinedUserId = (userId: number) => ) .execute(); -export async function updateMemberInGameName({ +export function updateMemberInGameName({ userId, inGameName, tournamentTeamId, @@ -67,12 +68,21 @@ export async function updateMemberInGameName({ inGameName: string; tournamentTeamId: number; }) { - return db - .updateTable("TournamentTeamMember") - .set({ inGameName }) - .where("TournamentTeamMember.userId", "=", userId) - .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) - .execute(); + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentTeamMember") + .set({ inGameName }) + .where("TournamentTeamMember.userId", "=", userId) + .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "UPDATE_IN_GAME_NAME", + tournamentTeamId, + subjectUserId: userId, + metadata: { inGameName }, + }); + }); } /** @@ -106,24 +116,20 @@ export async function updateOwnMemberInGameNameForNonStarted( export function create({ team, - avatarFileName, + avatarImgId = null, userId, + additionalMemberUserIds = [], tournamentId, }: { team: Pick; - avatarFileName?: string; + avatarImgId?: number | null; + /** The user who becomes the team owner. */ userId: number; + /** Non-owner members to add to the team on creation. */ + additionalMemberUserIds?: number[]; tournamentId: number; }) { return db.transaction().execute(async (trx) => { - const avatarImgId = avatarFileName - ? await createSubmittedImageInTrx({ - trx, - avatarFileName, - userId, - }) - : null; - const tournamentTeam = await trx .insertInto("TournamentTeam") .values({ @@ -149,10 +155,193 @@ export function create({ }) .execute(); + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_REGISTERED", + tournamentTeamId: tournamentTeam.id, + subjectUserId: userId, + }); + + for (const memberUserId of additionalMemberUserIds) { + const memberInGameName = await resolveInGameName( + trx, + tournamentId, + memberUserId, + ); + + await trx + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: tournamentTeam.id, + userId: memberUserId, + inGameName: memberInGameName, + }) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_ADDED", + tournamentTeamId: tournamentTeam.id, + subjectUserId: memberUserId, + }); + } + return tournamentTeam; }); } +/** + * Creates a new registration or applies a full-state edit to an existing one in a + * single transaction: team name, linked sendou.ink team, owner assignment/transfer, + * member adds/removes and in-game name updates. Pass `tournamentTeamId` to edit an + * existing team, or omit it to create a new one (all members are then "added" and + * `ownerUserId` becomes the owner). The caller is responsible for validating the + * derived ops and for any side effects (cache updates, notifications) outside the + * transaction. + */ +export function upsertRegistration({ + tournamentTeamId, + tournamentId, + name, + teamId, + avatarImgId, + ownerUserId, + ownerChange, + membersToAdd, + membersToRemove, + inGameNameUpdates, +}: { + /** Present when editing an existing team, omitted when creating a new one. */ + tournamentTeamId?: number; + tournamentId: number; + name: string; + /** Linked sendou.ink team id, or null for a pickup team. */ + teamId: number | null; + /** Resolved pickup team logo image id (null for none / linked teams). */ + avatarImgId: number | null; + /** Roster owner/captain. Assigned the OWNER role when creating a new team. */ + ownerUserId: number; + /** Owner transfer for an existing team (null when unchanged or when creating). */ + ownerChange: { oldOwnerId: number; newOwnerId: number } | null; + membersToAdd: number[]; + membersToRemove: number[]; + inGameNameUpdates: Array<{ userId: number; inGameName: string }>; +}) { + const isNew = typeof tournamentTeamId !== "number"; + + return db.transaction().execute(async (trx) => { + const id = isNew + ? ( + await trx + .insertInto("TournamentTeam") + .values({ + tournamentId, + name, + inviteCode: shortNanoid(), + prefersNotToHost: 0, + teamId, + avatarImgId, + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id + : tournamentTeamId; + + if (!isNew) { + const { activeRosterUserIds } = await trx + .selectFrom("TournamentTeam") + .select("TournamentTeam.activeRosterUserIds") + .where("TournamentTeam.id", "=", id) + .executeTakeFirstOrThrow(); + const clearActiveRoster = (activeRosterUserIds ?? []).some((memberId) => + membersToRemove.includes(memberId), + ); + + await trx + .updateTable("TournamentTeam") + .set({ + name, + teamId, + avatarImgId, + ...(clearActiveRoster ? { activeRosterUserIds: null } : {}), + }) + .where("TournamentTeam.id", "=", id) + .execute(); + + await TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId: id, + name, + }); + } + + for (const userId of membersToRemove) { + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_REMOVED", + tournamentTeamId: id, + subjectUserId: userId, + }); + + await trx + .deleteFrom("TournamentTeamMember") + .where("TournamentTeamMember.tournamentTeamId", "=", id) + .where("TournamentTeamMember.userId", "=", userId) + .execute(); + } + + for (const userId of membersToAdd) { + const isOwner = isNew && userId === ownerUserId; + const inGameName = await resolveInGameName(trx, tournamentId, userId); + + await trx + .insertInto("TournamentTeamMember") + .values({ + tournamentTeamId: id, + userId, + inGameName, + ...(isOwner ? { role: "OWNER" as const } : {}), + }) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: isOwner ? "TEAM_REGISTERED" : "MEMBER_ADDED", + tournamentTeamId: id, + subjectUserId: userId, + }); + } + + // after adds so a newly added member can be designated owner + if (ownerChange) { + await trx + .updateTable("TournamentTeamMember") + .set({ role: "REGULAR" }) + .where("TournamentTeamMember.tournamentTeamId", "=", id) + .where("TournamentTeamMember.userId", "=", ownerChange.oldOwnerId) + .execute(); + + await trx + .updateTable("TournamentTeamMember") + .set({ role: "OWNER" }) + .where("TournamentTeamMember.tournamentTeamId", "=", id) + .where("TournamentTeamMember.userId", "=", ownerChange.newOwnerId) + .execute(); + } + + for (const { userId, inGameName } of inGameNameUpdates) { + await trx + .updateTable("TournamentTeamMember") + .set({ inGameName }) + .where("TournamentTeamMember.tournamentTeamId", "=", id) + .where("TournamentTeamMember.userId", "=", userId) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "UPDATE_IN_GAME_NAME", + tournamentTeamId: id, + subjectUserId: userId, + metadata: { inGameName }, + }); + } + }); +} + async function resolveInGameName( trx: Transaction, tournamentId: number, @@ -280,29 +469,16 @@ export function copyFromAnotherTournament({ export function update({ team, - avatarFileName, + avatarImgId, }: { team: Pick< Tables["TournamentTeam"], "id" | "name" | "prefersNotToHost" | "teamId" >; - avatarFileName?: string; + /** Resolved logo image id. `null` clears the pickup avatar (e.g. when switching to a linked team). */ + avatarImgId: number | null; }) { - const userId = actorId(); return db.transaction().execute(async (trx) => { - const avatarImgId = avatarFileName - ? await createSubmittedImageInTrx({ - trx, - avatarFileName, - userId, - }) - : team.teamId - ? // clear pickup avatar when switching to team signup, as team logo will be used - null - : // don't overwrite the existing avatarImgId even if no new avatar is provided - // delete is a separate functionality - undefined; - await trx .updateTable("TournamentTeam") .set({ @@ -313,41 +489,14 @@ export function update({ }) .where("TournamentTeam.id", "=", team.id) .execute(); + + await TournamentAuditLogRepository.updateTeamHistoryName(trx, { + tournamentTeamId: team.id, + name: team.name, + }); }); } -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(); -} - export function updateStartingBrackets( startingBrackets: { tournamentTeamId: number; @@ -412,7 +561,7 @@ export function updateAbDivisions( */ export function checkIn( tournamentTeamId: number, - options?: { bracketIdx: number }, + options?: { bracketIdx?: number }, ) { const bracketIdx = options?.bracketIdx ?? null; @@ -442,6 +591,12 @@ export function checkIn( bracketIdx, }) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_CHECKED_IN", + tournamentTeamId, + metadata: typeof bracketIdx === "number" ? { bracketIdx } : null, + }); }); } @@ -474,23 +629,13 @@ export function checkOut({ }) .execute(); } - }); -} -export function updateName({ - tournamentTeamId, - name, -}: { - tournamentTeamId: number; - name: string; -}) { - return db - .updateTable("TournamentTeam") - .set({ - name, - }) - .where("id", "=", tournamentTeamId) - .execute(); + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_CHECKED_OUT", + tournamentTeamId, + metadata: typeof bracketIdx === "number" ? { bracketIdx } : null, + }); + }); } export function dropOut({ @@ -514,17 +659,29 @@ export function dropOut({ }) .where("id", "=", tournamentTeamId) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_DROPPED_OUT", + tournamentTeamId, + }); }); } export function undoDropOut(tournamentTeamId: number) { - return db - .updateTable("TournamentTeam") - .set({ - droppedOut: 0, - }) - .where("id", "=", tournamentTeamId) - .execute(); + return db.transaction().execute(async (trx) => { + await trx + .updateTable("TournamentTeam") + .set({ + droppedOut: 0, + }) + .where("id", "=", tournamentTeamId) + .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_DROP_OUT_UNDONE", + tournamentTeamId, + }); + }); } export function join({ @@ -537,16 +694,26 @@ export function join({ previousTeamId?: number; whatToDoWithPreviousTeam?: "LEAVE" | "DELETE"; newTeamId: number; + /** The user joining the team. */ userId: number; checkOutTeam?: boolean; }) { return db.transaction().execute(async (trx) => { if (whatToDoWithPreviousTeam === "DELETE") { + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_UNREGISTERED", + tournamentTeamId: previousTeamId!, + }); await trx .deleteFrom("TournamentTeam") .where("TournamentTeam.id", "=", previousTeamId!) .execute(); } else if (whatToDoWithPreviousTeam === "LEAVE") { + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_REMOVED", + tournamentTeamId: previousTeamId!, + subjectUserId: userId, + }); await trx .deleteFrom("TournamentTeamMember") .where("TournamentTeamMember.tournamentTeamId", "=", previousTeamId!) @@ -583,11 +750,22 @@ export function join({ inGameName, }) .execute(); + + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_ADDED", + tournamentTeamId: newTeamId, + subjectUserId: userId, + }); }); } export function del(tournamentTeamId: number) { return db.transaction().execute(async (trx) => { + await TournamentAuditLogRepository.insert(trx, { + type: "TEAM_UNREGISTERED", + tournamentTeamId, + }); + await trx .deleteFrom("MapPoolMap") .where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId) @@ -600,34 +778,25 @@ export function del(tournamentTeamId: number) { }); } -export function leave({ teamId, userId }: { teamId: number; userId: number }) { - return db - .deleteFrom("TournamentTeamMember") - .where("TournamentTeamMember.tournamentTeamId", "=", teamId) - .where("TournamentTeamMember.userId", "=", userId) - .execute(); -} - -export function transferOwnership( - tournamentTeamId: number, - { - oldCaptainId, - newCaptainId, - }: { oldCaptainId: number; newCaptainId: number }, -) { +export function leave({ + teamId, + userId, +}: { + teamId: number; + /** The member leaving the team. */ + userId: number; +}) { return db.transaction().execute(async (trx) => { - await trx - .updateTable("TournamentTeamMember") - .set({ role: "REGULAR" }) - .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) - .where("TournamentTeamMember.userId", "=", oldCaptainId) - .execute(); + await TournamentAuditLogRepository.insert(trx, { + type: "MEMBER_REMOVED", + tournamentTeamId: teamId, + subjectUserId: userId, + }); await trx - .updateTable("TournamentTeamMember") - .set({ role: "OWNER" }) - .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) - .where("TournamentTeamMember.userId", "=", newCaptainId) + .deleteFrom("TournamentTeamMember") + .where("TournamentTeamMember.tournamentTeamId", "=", teamId) + .where("TournamentTeamMember.userId", "=", userId) .execute(); }); } diff --git a/app/features/tournament/actions/to.$id.admin.server.ts b/app/features/tournament/actions/to.$id.admin.server.ts deleted file mode 100644 index 576683809..000000000 --- a/app/features/tournament/actions/to.$id.admin.server.ts +++ /dev/null @@ -1,532 +0,0 @@ -import type { ActionFunction } from "react-router"; -import * as R from "remeda"; -import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; -import { requireUser } from "~/features/auth/core/user.server"; -import { userIsBanned } from "~/features/ban/core/banned.server"; -import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; -import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; -import { notify } from "~/features/notifications/core/notify.server"; -import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server"; -import * as Progression from "~/features/tournament-bracket/core/Progression"; -import { - clearTournamentDataCache, - tournamentFromDB, -} from "~/features/tournament-bracket/core/Tournament.server"; -import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils"; -import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; -import { tournamentMatchWebsocketRoom } from "~/features/tournament-match/tournament-match-utils"; -import * as UserRepository from "~/features/user-page/UserRepository.server"; -import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; -import { - badRequestIfFalsy, - errorToastIfFalsy, - parseParams, - parseRequestPayload, - successToast, -} from "~/utils/remix.server"; -import { assertUnreachable } from "~/utils/types"; -import { idObject } from "../../../utils/zod"; -import * as TournamentRepository from "../TournamentRepository.server"; -import { adminActionSchema } from "../tournament-schemas.server"; -import { endDroppedTeamMatches } from "../tournament-utils.server"; - -export const action: ActionFunction = async ({ request, params }) => { - const user = requireUser(); - const data = await parseRequestPayload({ - request, - schema: adminActionSchema, - }); - - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - const validateIsTournamentAdmin = () => - errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized"); - const validateIsTournamentOrganizer = () => - errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized"); - - let message: string; - switch (data._action) { - case "ADD_TEAM": { - validateIsTournamentOrganizer(); - errorToastIfFalsy( - tournament.ctx.teams.every((t) => t.name !== data.teamName), - "Team name taken", - ); - errorToastIfFalsy( - !tournament.teamMemberOfByUser({ id: data.userId }), - "User already on a team", - ); - const addTeamUser = await UserRepository.findLeanById(data.userId); - errorToastIfFalsy(addTeamUser?.friendCode, "User has no friend code set"); - errorToastIfFalsy( - !tournament.ctx.settings.requireInGameNames || addTeamUser?.inGameName, - "User has no in-game name set", - ); - - await TournamentTeamRepository.create({ - team: { - name: data.teamName, - prefersNotToHost: 0, - teamId: null, - }, - userId: data.userId, - tournamentId, - }); - await TournamentLFGRepository.leaveLfg({ - userId: data.userId, - tournamentId, - }); - - ShowcaseTournaments.addToCached({ - tournamentId, - type: "participant", - userId: data.userId, - newTeamCount: tournament.ctx.teams.length + 1, - }); - - message = "Team added"; - break; - } - case "CHANGE_TEAM_OWNER": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - const oldCaptain = team.members.find((m) => m.role === "OWNER"); - invariant(oldCaptain, "Team has no captain"); - const newCaptain = team.members.find((m) => m.userId === data.memberId); - errorToastIfFalsy(newCaptain, "Invalid member id"); - - await TournamentTeamRepository.transferOwnership(data.teamId, { - oldCaptainId: oldCaptain.userId, - newCaptainId: data.memberId, - }); - - message = "Team owner changed"; - break; - } - case "CHANGE_TEAM_NAME": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - - await TournamentTeamRepository.updateName({ - tournamentTeamId: data.teamId, - name: data.teamName, - }); - - message = "Team name changed"; - break; - } - case "CHECK_IN": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - errorToastIfFalsy( - data.bracketIdx !== 0 || - tournament.checkInConditionsFulfilledByTeamId(team.id).isFulfilled, - `Can't check-in - ${tournament.checkInConditionsFulfilledByTeamId(team.id).reason}`, - ); - errorToastIfFalsy( - team.checkIns.length > 0 || data.bracketIdx === 0, - "Can't check-in to follow up bracket if not checked in for the event itself", - ); - - const bracket = tournament.bracketByIdx(data.bracketIdx); - invariant(bracket, "Invalid bracket idx"); - errorToastIfFalsy(bracket.preview, "Bracket has been started"); - - await TournamentTeamRepository.checkIn( - data.teamId, - // no sources = regular check in - bracket.sources ? { bracketIdx: data.bracketIdx } : undefined, - ); - - message = "Checked team in"; - break; - } - case "CHECK_OUT": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - errorToastIfFalsy( - data.bracketIdx !== 0 || !tournament.hasStarted, - "Tournament has started", - ); - - const bracket = tournament.bracketByIdx(data.bracketIdx); - invariant(bracket, "Invalid bracket idx"); - errorToastIfFalsy(bracket.preview, "Bracket has been started"); - - await TournamentTeamRepository.checkOut({ - tournamentTeamId: data.teamId, - // no sources = regular check in - bracketIdx: !bracket.sources ? null : data.bracketIdx, - }); - logger.info( - `Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`, - ); - - message = "Checked team out"; - break; - } - case "REMOVE_MEMBER": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - errorToastIfFalsy( - team.checkIns.length === 0 || - team.members.length > tournament.minMembersPerTeam, - "Can't remove last member from checked in team", - ); - errorToastIfFalsy( - team.members.find((m) => m.userId === data.memberId)?.role !== "OWNER", - "Cannot remove team owner", - ); - errorToastIfFalsy( - !tournament.hasStarted || - !tournament - .participatedPlayersByTeamId(data.teamId) - .some((p) => p.userId === data.memberId), - "Cannot remove player that has participated in the tournament", - ); - - if (team.activeRosterUserIds?.includes(data.memberId)) { - await TournamentTeamRepository.setActiveRoster({ - teamId: team.id, - activeRosterUserIds: null, - }); - } - - await TournamentTeamRepository.leave({ - userId: data.memberId, - teamId: team.id, - }); - - ShowcaseTournaments.removeFromCached({ - tournamentId, - type: "participant", - userId: data.memberId, - }); - - message = "Member removed"; - break; - } - case "ADD_MEMBER": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - - const previousTeam = tournament.teamMemberOfByUser({ id: data.userId }); - - errorToastIfFalsy( - !previousTeam?.id || previousTeam.id !== team.id, - "User is already in this team", - ); - - errorToastIfFalsy( - tournament.hasStarted || !previousTeam, - "User is already in a team", - ); - - errorToastIfFalsy( - !userIsBanned(data.userId), - "User trying to be added currently has an active ban from sendou.ink", - ); - - const addMemberUser = await UserRepository.findLeanById(data.userId); - errorToastIfFalsy( - addMemberUser?.friendCode, - "User has no friend code set", - ); - errorToastIfFalsy( - !tournament.ctx.settings.requireInGameNames || - addMemberUser?.inGameName, - "User has no in-game name set", - ); - - await TournamentLFGRepository.leaveLfg({ - userId: data.userId, - tournamentId, - }); - await TournamentTeamRepository.join({ - userId: data.userId, - newTeamId: team.id, - previousTeamId: previousTeam?.id, - // this team is not checked in & tournament started, so we can simply delete it - whatToDoWithPreviousTeam: - previousTeam && - previousTeam.checkIns.length === 0 && - tournament.hasStarted - ? "DELETE" - : undefined, - }); - - ShowcaseTournaments.addToCached({ - tournamentId, - type: "participant", - userId: data.userId, - }); - - if (!tournament.isTest && !tournament.isDraft) { - notify({ - userIds: [data.userId], - notification: { - type: "TO_ADDED_TO_TEAM", - pictureUrl: - tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl, - meta: { - adderUsername: user.username, - teamName: team.name, - tournamentId, - tournamentName: tournament.ctx.name, - tournamentTeamId: team.id, - }, - }, - }); - } - - message = "Member added"; - break; - } - case "DELETE_TEAM": { - validateIsTournamentOrganizer(); - const team = tournament.teamById(data.teamId); - errorToastIfFalsy(team, "Invalid team id"); - errorToastIfFalsy(!tournament.hasStarted, "Tournament has started"); - - await TournamentTeamRepository.del(team.id); - - for (const member of team.members) { - ShowcaseTournaments.removeFromCached({ - tournamentId, - type: "participant", - userId: member.userId, - }); - - ShowcaseTournaments.updateCachedTournamentTeamCount({ - tournamentId, - newTeamCount: tournament.ctx.teams.length - 1, - }); - } - - message = "Team deleted from tournament"; - - break; - } - case "ADD_STAFF": { - validateIsTournamentAdmin(); - - errorToastIfFalsy( - tournament.ctx.staff.every((staff) => staff.id !== data.userId), - "User is already a staff member", - ); - - await TournamentRepository.addStaff({ - role: data.role, - tournamentId: tournament.ctx.id, - userId: data.userId, - }); - - if (data.role === "ORGANIZER") { - ShowcaseTournaments.addToCached({ - tournamentId, - type: "organizer", - userId: data.userId, - }); - } - - message = "Staff member added"; - break; - } - case "REMOVE_STAFF": { - validateIsTournamentAdmin(); - - await TournamentRepository.removeStaff({ - tournamentId: tournament.ctx.id, - userId: data.userId, - }); - - ShowcaseTournaments.removeFromCached({ - tournamentId, - type: "organizer", - userId: data.userId, - }); - - message = "Staff member removed"; - break; - } - case "UPDATE_CAST_TWITCH_ACCOUNTS": { - validateIsTournamentOrganizer(); - await TournamentRepository.updateCastTwitchAccounts({ - tournamentId: tournament.ctx.id, - castTwitchAccounts: data.castTwitchAccounts, - }); - - message = "Cast account updated"; - break; - } - case "DROP_TEAM_OUT": { - validateIsTournamentOrganizer(); - const droppingTeam = tournament.teamById(data.teamId); - errorToastIfFalsy(droppingTeam, "Invalid team id"); - - // Set active roster only for teams with subs (can't infer which players played) - // Teams without subs have their roster trivially inferred in summarizer - const hasSubs = - droppingTeam.members.length > tournament.minMembersPerTeam; - if (hasSubs && !droppingTeam.activeRosterUserIds) { - const randomRoster = R.sample( - droppingTeam.members.map((m) => m.userId), - tournament.minMembersPerTeam, - ); - await TournamentTeamRepository.setActiveRoster({ - teamId: data.teamId, - activeRosterUserIds: randomRoster, - }); - } - - const endedMatchIds = endDroppedTeamMatches({ - tournament, - manager: getServerTournamentManager(), - droppedTeamId: data.teamId, - }); - - await TournamentTeamRepository.dropOut({ - tournamentTeamId: data.teamId, - previewBracketIdxs: tournament.brackets.flatMap((b, idx) => - b.preview ? idx : [], - ), - }); - - if (endedMatchIds.length > 0) { - ChatSystemMessage.send([ - ...endedMatchIds.map((matchId) => ({ - room: tournamentMatchWebsocketRoom(matchId), - type: "TOURNAMENT_MATCH_UPDATED" as const, - revalidateOnly: true as const, - authorUserId: user.id, - })), - { - room: tournamentWebsocketRoom(tournament.ctx.id), - type: "TOURNAMENT_UPDATED" as const, - revalidateOnly: true as const, - authorUserId: user.id, - }, - ]); - } - - message = "Team dropped out"; - break; - } - case "UNDO_DROP_TEAM_OUT": { - validateIsTournamentOrganizer(); - - await TournamentTeamRepository.undoDropOut(data.teamId); - - message = "Team drop out undone"; - break; - } - case "RESET_BRACKET": { - validateIsTournamentOrganizer(); - errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); - - const bracketToResetIdx = tournament.brackets.findIndex( - (b) => b.id === data.stageId, - ); - const bracketToReset = tournament.brackets[bracketToResetIdx]; - errorToastIfFalsy(bracketToReset, "Invalid bracket id"); - errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started"); - - const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); - errorToastIfFalsy( - inProgressBrackets.every( - (b) => - !b.sources || - b.sources.every((s) => s.bracketIdx !== bracketToResetIdx), - ), - "Some bracket that sources teams from this bracket has started", - ); - - await TournamentRepository.resetBracket(data.stageId); - - message = "Bracket reset"; - break; - } - case "UPDATE_IN_GAME_NAME": { - validateIsTournamentOrganizer(); - - const teamMemberOf = badRequestIfFalsy( - tournament.teamMemberOfByUser({ id: data.memberId }), - ); - - await TournamentTeamRepository.updateMemberInGameName({ - userId: data.memberId, - inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`, - tournamentTeamId: teamMemberOf.id, - }); - - message = "Player in-game name updated"; - break; - } - case "DELETE_LOGO": { - validateIsTournamentOrganizer(); - - await TournamentTeamRepository.deleteLogo(data.teamId); - - message = "Logo deleted"; - break; - } - case "UPDATE_TOURNAMENT_PROGRESSION": { - validateIsTournamentOrganizer(); - errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized"); - - errorToastIfFalsy( - Progression.changedBracketProgression( - tournament.ctx.settings.bracketProgression, - data.bracketProgression, - ).every( - (changedBracketIdx) => - tournament.bracketByIdx(changedBracketIdx)?.preview, - ), - "Can't change started brackets", - ); - - await TournamentRepository.updateProgression({ - tournamentId: tournament.ctx.id, - bracketProgression: data.bracketProgression, - }); - - message = "Tournament progression updated"; - break; - } - case "REOPEN_TOURNAMENT": { - validateIsTournamentAdmin(); - errorToastIfFalsy( - DANGEROUS_CAN_ACCESS_DEV_CONTROLS, - "Only available in development", - ); - errorToastIfFalsy( - tournament.ctx.isFinalized, - "Tournament is not finalized", - ); - - await TournamentRepository.reopenTournament(tournamentId); - - message = "Tournament reopened"; - break; - } - default: { - assertUnreachable(data); - } - } - - clearTournamentDataCache(tournamentId); - - return successToast(message); -}; diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 6e567c0da..2d065720d 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -13,13 +13,9 @@ import { } from "~/features/tournament-bracket/core/Tournament.server"; import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; +import { parseFormDataWithImages } from "~/form/parse.server"; import { logger } from "~/utils/logger"; -import { - errorToastIfFalsy, - parseFormData, - parseParams, - uploadImageIfSubmitted, -} from "~/utils/remix.server"; +import { errorToastIfFalsy, parseParams } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { idObject } from "~/utils/zod"; import { TOURNAMENT } from "../tournament-constants"; @@ -35,58 +31,64 @@ import { export const action: ActionFunction = async ({ request, params }) => { const user = requireUser(); - const { avatarFileName, formData } = await uploadImageIfSubmitted({ - request, - fileNamePrefix: "pickup-logo", - }); - const data = await parseFormData({ - formData, - schema: registerSchema, - }); - const { id: tournamentId } = parseParams({ params, schema: idObject, }); + const tournament = await tournamentFromDB({ tournamentId, user }); + const ownTeam = tournament.ownedTeamByUser(user); + + const result = await parseFormDataWithImages({ + request, + schema: registerSchema({ tournament, ownTeamId: ownTeam?.id }), + }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; errorToastIfFalsy( !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": { + const linkedTeamId = data.teamId ? Number(data.teamId) : null; + errorToastIfFalsy( - !data.teamId || + !linkedTeamId || (await TeamRepository.findAllMemberOfByUserId(user.id)).some( - (team) => team.id === data.teamId, + (team) => team.id === linkedTeamId, ), "Team id does not match any of the teams you are in", ); + // linked teams source their name and logo from the sendou.ink team + const name = ( + linkedTeamId + ? (await TeamRepository.findById(linkedTeamId))?.name + : data.pickUpName + )!; + + const avatarImgId = linkedTeamId ? null : data.logo; + if (ownTeam) { errorToastIfFalsy( - tournament.registrationOpen || data.teamName === ownTeam.name, + tournament.registrationOpen || name === ownTeam.name, "Can't change team name after registration has closed", ); - errorToastIfFalsy( - !tournament.ctx.teams.some( - (team) => team.name === data.teamName && team.id !== ownTeam.id, - ), - "Team name already taken for this tournament", - ); await TournamentTeamRepository.update({ - avatarFileName, + avatarImgId, team: { id: ownTeam.id, - name: data.teamName, + name, prefersNotToHost: Number(data.prefersNotToHost), - teamId: data.teamId ?? null, + teamId: linkedTeamId, }, }); } else { @@ -112,10 +114,6 @@ export const action: ActionFunction = async ({ request, params }) => { tournament.registrationOpen, "Registration is closed", ); - errorToastIfFalsy( - !tournament.ctx.teams.some((team) => team.name === data.teamName), - "Team name already taken for this tournament", - ); await TournamentLFGRepository.leaveLfg({ userId: user.id, @@ -123,13 +121,13 @@ export const action: ActionFunction = async ({ request, params }) => { }); await TournamentTeamRepository.create({ team: { - name: data.teamName, + name, prefersNotToHost: Number(data.prefersNotToHost), - teamId: data.teamId ?? null, + teamId: linkedTeamId, }, userId: user.id, tournamentId, - avatarFileName, + avatarImgId, }); await SavedCalendarEventRepository.unsave({ userId: user.id, @@ -346,13 +344,6 @@ export const action: ActionFunction = async ({ request, params }) => { break; } - case "DELETE_LOGO": { - errorToastIfFalsy(ownTeam, "You are not registered to this tournament"); - - await TournamentTeamRepository.deleteLogo(ownTeam.id); - - break; - } case "SAVE_TOURNAMENT": { const count = await SavedCalendarEventRepository.countByUserId(user.id); errorToastIfFalsy( diff --git a/app/features/tournament/components/FactCard.module.css b/app/features/tournament/components/FactCard.module.css new file mode 100644 index 000000000..6cb422312 --- /dev/null +++ b/app/features/tournament/components/FactCard.module.css @@ -0,0 +1,62 @@ +.container { + container-type: inline-size; +} + +.wrapper { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: var(--s-8); + width: fit-content; + max-width: 100%; + margin-inline: auto; +} + +.column { + display: flex; + flex-direction: column; + gap: var(--s-3); +} + +.divider { + min-width: 2px; + background-color: var(--color-border-high); + border-radius: var(--radius-full); + flex-shrink: 0; +} + +.card { + display: grid; + grid-template-columns: minmax(0, 6rem) minmax(0, max-content); + align-items: center; + gap: var(--s-3); +} + +.label { + font-size: var(--font-xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + text-transform: uppercase; +} + +.value { + font-size: var(--font-sm); + font-weight: var(--weight-semi); + color: var(--color-text); + min-width: 0; + display: flex; + align-items: center; + gap: var(--s-2); + flex-wrap: wrap; +} + +@container (max-width: 480px) { + .wrapper { + grid-template-columns: auto; + gap: var(--s-3); + } + + .divider { + display: none; + } +} diff --git a/app/features/tournament/components/FactCard.tsx b/app/features/tournament/components/FactCard.tsx new file mode 100644 index 000000000..ff1769aa8 --- /dev/null +++ b/app/features/tournament/components/FactCard.tsx @@ -0,0 +1,43 @@ +import type * as React from "react"; +import styles from "./FactCard.module.css"; + +export interface FactCardItem { + label: string; + value: React.ReactNode; +} + +export function FactCardGrid({ facts }: { facts: FactCardItem[] }) { + const leftFacts = facts.filter((_, i) => i % 2 === 0); + const rightFacts = facts.filter((_, i) => i % 2 === 1); + + return ( +
+
+
+ {leftFacts.map((fact) => ( + + ))} +
+ {rightFacts.length > 0 ? ( + <> + +
+ ); +} + +function Card({ label, value }: FactCardItem) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/app/features/tournament/components/RegistrationActions.tsx b/app/features/tournament/components/RegistrationActions.tsx new file mode 100644 index 000000000..7daa93bdd --- /dev/null +++ b/app/features/tournament/components/RegistrationActions.tsx @@ -0,0 +1,38 @@ +import { ClipboardCheck, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { LinkButton } from "~/components/elements/Button"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { tournamentRegisterPage, tournamentSubsPage } from "~/utils/urls"; + +export function RegistrationActions({ + tournament, +}: { + tournament: Tournament; +}) { + const { t } = useTranslation(["tournament"]); + + if (!tournament.registrationOpen) return null; + + return ( +
+ } + testId="register-cta" + > + {t("tournament:registerNow")} + + {tournament.lfgEnabled ? ( + } + > + {t("tournament:findTeam")} + + ) : null} +
+ ); +} diff --git a/app/features/tournament/components/TournamentHeader.module.css b/app/features/tournament/components/TournamentHeader.module.css new file mode 100644 index 000000000..2713e6ddb --- /dev/null +++ b/app/features/tournament/components/TournamentHeader.module.css @@ -0,0 +1,92 @@ +.header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-4); + text-align: center; + container-type: inline-size; +} + +.identity { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-3); +} + +.titleBlock { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--s-2); +} + +@container (min-width: 448px) { + .identity { + flex-direction: row; + gap: var(--s-6); + } +} + +.logo { + border-radius: var(--radius-avatar); +} + +.nameBlock { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--s-1); + width: max-content; + max-width: 100%; +} + +.name { + font-size: var(--font-xl); + font-weight: var(--weight-bold); + margin: 0; + text-wrap: balance; + text-align: center; + line-height: 0.9; +} + +.subtext { + display: flex; + align-items: center; + gap: var(--s-2); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + color: var(--color-text-high); + + &::before, + &::after { + content: ""; + flex: 1; + border-bottom: 2px solid var(--color-text-high); + } +} + +.organizer { + display: inline-flex; + align-items: center; + gap: var(--s-2); + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-semi); +} + +.dates { + display: flex; + flex-direction: column; + gap: var(--s-1); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + color: var(--color-text-high); +} + +.actions { + display: flex; + gap: var(--s-2); + align-items: center; + justify-content: center; +} diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx new file mode 100644 index 000000000..266ce637c --- /dev/null +++ b/app/features/tournament/components/TournamentHeader.tsx @@ -0,0 +1,212 @@ +import { Bookmark, BookmarkCheck, Share2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Link, useFetcher } from "react-router"; +import * as R from "remeda"; +import { Avatar } from "~/components/Avatar"; +import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover"; +import { LinkButton, SendouButton } from "~/components/elements/Button"; +import { DiscordIcon } from "~/components/icons/Discord"; +import TimePopover from "~/components/TimePopover"; +import { useUser } from "~/features/auth/core/user"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { + SENDOU_INK_BASE_URL, + tournamentOrganizationPage, + tournamentPage, + userPage, +} from "~/utils/urls"; +import { splitTournamentName } from "../tournament-utils"; +import styles from "./TournamentHeader.module.css"; + +export function TournamentHeader({ tournament }: { tournament: Tournament }) { + const { name, subtext } = splitTournamentName( + tournament.ctx.name, + tournament.ctx.organization?.series ?? [], + ); + + const startTimes = R.uniqueBy( + [ + tournament.ctx.startTime, + ...tournament.ctx.settings.bracketProgression + .filter((b) => b.startTime) + .map((b) => databaseTimestampToDate(b.startTime!)), + ], + (date) => date.getTime(), + ); + + const currentYear = new Date().getFullYear(); + + return ( +
+
+ +
+
+

{name}

+ {subtext ?
{subtext}
: null} +
+ +
+
+
+ {startTimes.map((date) => ( + + ))} +
+
+ ); +} + +export function TournamentHeaderActions({ + tournament, + isSaved, +}: { + tournament: Tournament; + isSaved: boolean; +}) { + return ( +
+ + {tournament.ctx.discordUrl ? ( + } + aria-label="Discord" + /> + ) : null} + +
+ ); +} + +function SaveTournamentButton({ + tournament, + isSaved, +}: { + tournament: Tournament; + isSaved: boolean; +}) { + const { t } = useTranslation(["common"]); + const user = useUser(); + const fetcher = useFetcher(); + + const teamMemberOf = tournament.teamMemberOfByUser(user); + if (!user || tournament.hasStarted || teamMemberOf) return null; + + const pending = fetcher.formData?.get("_action"); + const displayedSaved = + pending === "SAVE_TOURNAMENT" + ? true + : pending === "UNSAVE_TOURNAMENT" + ? false + : isSaved; + + return ( + + + : } + aria-label={ + displayedSaved ? t("common:actions.unsave") : t("common:actions.save") + } + /> + + ); +} + +function OrganizerLink({ tournament }: { tournament: Tournament }) { + if (tournament.ctx.organization) { + return ( + + + {tournament.ctx.organization.name} + + ); + } + + return ( + + + {tournament.ctx.author.username} + + ); +} + +function ShareTournamentButton({ tournament }: { tournament: Tournament }) { + const { t } = useTranslation(["common"]); + const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`; + + const handleShare = () => { + navigator.share({ url }); + }; + + if ( + typeof navigator !== "undefined" && + typeof navigator.share === "function" + ) { + return ( + } + onPress={handleShare} + aria-label={t("common:actions.share")} + /> + ); + } + + return ( + } + aria-label={t("common:actions.share")} + /> + } + /> + ); +} diff --git a/app/features/tournament/components/TournamentNav.module.css b/app/features/tournament/components/TournamentNav.module.css new file mode 100644 index 000000000..3a2d974ee --- /dev/null +++ b/app/features/tournament/components/TournamentNav.module.css @@ -0,0 +1,157 @@ +.nav { + display: flex; + align-items: center; + gap: var(--s-3); + padding: var(--s-2) 0; + margin-block-end: var(--s-4); + min-width: 0; +} + +.identity { + display: flex; + align-items: center; + gap: var(--s-2); + color: var(--color-text); + text-decoration: none; + min-width: 0; + flex-shrink: 0; +} + +.identityText { + display: flex; + flex-direction: column; + gap: var(--s-0-5); + min-width: 0; +} + +.identityName { + font-size: var(--font-sm); + font-weight: var(--weight-bold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 14ch; + margin: 0 auto; +} + +.identitySubtext { + display: flex; + align-items: center; + gap: var(--s-1); + font-size: var(--font-2xs); + font-weight: var(--weight-semi); + color: var(--color-text-high); + + &::before, + &::after { + content: ""; + flex: 1; + border-bottom: 1.5px solid var(--color-text-high); + } +} + +.separator { + flex-shrink: 0; + width: 2px; + height: 28px; + background-color: var(--color-border); +} + +.itemsWrapper { + position: relative; + flex: 1; + min-width: 0; + overflow: hidden; +} + +.items { + list-style: none; + margin: 0; + padding: 0; + display: flex; + gap: var(--s-0-5); + flex-wrap: nowrap; + white-space: nowrap; +} + +.itemSlot[data-hidden="true"] { + visibility: hidden; + pointer-events: none; +} + +.link { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + padding: var(--s-1) var(--s-2-5); + border-radius: var(--radius-field); + color: var(--color-text); + font-size: var(--font-xs); + font-weight: var(--weight-semi); + text-decoration: none; + cursor: pointer; + min-width: max-content; + + &:hover { + background-color: var(--color-bg-high); + } +} + +.linkActive { + color: var(--color-text-accent); + background-color: var(--color-bg-high); +} + +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.icon > svg { + width: 16px; + height: 16px; +} + +.label { + white-space: nowrap; +} + +.hamburger { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + flex-shrink: 0; +} + +.overflowList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 12rem; +} + +.overflowLink { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-2) var(--s-3); + border-radius: var(--radius-field); + color: var(--color-text); + font-size: var(--font-sm); + font-weight: var(--weight-semi); + text-decoration: none; + + &:hover { + background-color: var(--color-bg-high); + } +} + +.overflowLink.linkActive { + color: var(--color-text-accent); +} diff --git a/app/features/tournament/components/TournamentNav.tsx b/app/features/tournament/components/TournamentNav.tsx new file mode 100644 index 000000000..70f260a33 --- /dev/null +++ b/app/features/tournament/components/TournamentNav.tsx @@ -0,0 +1,356 @@ +import clsx from "clsx"; +import { + ClipboardCheck, + LayoutGrid, + Medal, + Menu, + ScrollText, + Settings, + Trophy, + Tv, + UserPlus, + Users, +} from "lucide-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { NavLink } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouButton } from "~/components/elements/Button"; +import { SendouPopover } from "~/components/elements/Popover"; +import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; +import { useUser } from "~/features/auth/core/user"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; +import { + tournamentDivisionsPage, + tournamentInfoPage, + tournamentRulesPage, +} from "~/utils/urls"; +import { splitTournamentName } from "../tournament-utils"; +import styles from "./TournamentNav.module.css"; + +type NavItemKey = + | "register" + | "brackets" + | "teams" + | "divisions" + | "streams" + | "results" + | "rules" + | "lfg" + | "admin"; + +interface NavItem { + key: NavItemKey; + label: string; + to: string; + icon: React.ReactNode; + end?: boolean; + testId?: string; +} + +const PRIORITY_ORDER: NavItemKey[] = [ + "register", + "brackets", + "teams", + "results", + "lfg", + "divisions", + "streams", + "rules", + "admin", +]; + +export function TournamentNav({ + tournament, + hasChildTournaments, +}: { + tournament: Tournament; + hasChildTournaments: boolean; +}) { + const { t } = useTranslation(["tournament"]); + const navItems = useNavItems({ tournament, hasChildTournaments }); + const { visibleCount, containerRef, measureRef } = useNavOverflow( + navItems.length, + ); + const [overflowOpen, setOverflowOpen] = React.useState(false); + + const overflowItems = navItems.slice(visibleCount); + + const { name, subtext } = splitTournamentName( + tournament.ctx.name, + tournament.ctx.organization?.series ?? [], + ); + + const homeHref = tournament.isLeagueDivision + ? tournamentInfoPage(tournament.ctx.parentTournamentId!) + : tournamentInfoPage(tournament.ctx.id); + + return ( + + ); +} + +function useNavItems({ + tournament, + hasChildTournaments, +}: { + tournament: Tournament; + hasChildTournaments: boolean; +}): NavItem[] { + const { t } = useTranslation(["tournament"]); + const user = useUser(); + + const items: Partial> = {}; + + if (tournament.registrationOpen) { + items.register = { + key: "register", + label: t("tournament:nav.register"), + to: "register", + icon: , + testId: "register-tab", + }; + } + + const showBrackets = !tournament.isLeagueSignup; + if (showBrackets) { + items.brackets = { + key: "brackets", + label: t("tournament:nav.brackets"), + to: "brackets", + icon: , + testId: "brackets-tab", + }; + } + + const showTeams = !(tournament.isLeagueSignup && hasChildTournaments); + if (showTeams) { + items.teams = { + key: "teams", + label: t("tournament:nav.teams", { + count: tournament.ctx.teams.length, + }), + to: "teams", + icon: , + end: false, + testId: "teams-tab", + }; + } + + if (tournament.isLeagueSignup || tournament.isLeagueDivision) { + items.divisions = { + key: "divisions", + label: t("tournament:nav.divisions"), + to: tournamentDivisionsPage( + tournament.ctx.parentTournamentId ?? tournament.ctx.id, + ), + icon: , + }; + } + + if (tournament.hasStarted && !tournament.everyBracketOver) { + items.streams = { + key: "streams", + label: t("tournament:nav.streams", { + count: tournament.streams.length, + }), + to: "streams", + icon: , + }; + } + + if (tournament.hasStarted) { + items.results = { + key: "results", + label: t("tournament:nav.results"), + to: "results", + icon: , + testId: "results-tab", + }; + } + + if (tournament.hasRulesPage) { + items.rules = { + key: "rules", + label: t("tournament:nav.rules"), + to: tournamentRulesPage(tournament.ctx.id), + icon: , + }; + } + + const showLfg = + !tournament.isInvitational && + !tournament.everyBracketOver && + !(tournament.isLeagueSignup && !tournament.registrationOpen) && + tournament.lfgEnabled; + if (showLfg) { + items.lfg = { + key: "lfg", + label: tournament.registrationOpen + ? t("tournament:nav.looking") + : t("tournament:nav.subs"), + to: "looking", + icon: , + }; + } + + const showAdmin = + tournament.isOrganizer(user) && + (!tournament.ctx.isFinalized || DANGEROUS_CAN_ACCESS_DEV_CONTROLS); + if (showAdmin) { + items.admin = { + key: "admin", + label: t("tournament:nav.admin"), + to: "admin", + icon: , + end: false, + testId: "admin-tab", + }; + } + + return PRIORITY_ORDER.flatMap((key) => (items[key] ? [items[key]!] : [])); +} + +function NavItemLink({ + item, + overflow = false, + onNavigate, +}: { + item: NavItem; + overflow?: boolean; + onNavigate?: () => void; +}) { + return ( + + clsx(overflow ? styles.overflowLink : styles.link, { + [styles.linkActive]: isActive, + }) + } + onClick={onNavigate} + data-testid={item.testId} + > + + {item.label} + + ); +} + +// horizontal space reserved on the right for the overflow hamburger: its big icon +// box (var(--button-icon-big) = 28px) plus breathing room before the last item. +// The hamburger is positioned absolutely so it never shrinks the measured container. +const HAMBURGER_WIDTH = 36; + +function useNavOverflow(totalItems: number) { + const containerRef = React.useRef(null); + const measureRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState(totalItems); + + useIsomorphicLayoutEffect(() => { + const container = containerRef.current; + const list = measureRef.current; + if (!container || !list) return; + + const slots = Array.from(list.children) as HTMLElement[]; + + const computeVisible = () => { + const containerWidth = container.getBoundingClientRect().width; + const listLeft = list.getBoundingClientRect().left; + // actual rendered right edge of each slot relative to the list start, + // so the real flex gaps are accounted for without re-deriving them + const rightEdges = slots.map( + (slot) => slot.getBoundingClientRect().right - listLeft, + ); + + const totalWidth = rightEdges.at(-1) ?? 0; + if (totalWidth <= containerWidth) { + setVisibleCount(slots.length); + return; + } + + const available = containerWidth - HAMBURGER_WIDTH; + let count = 0; + for (const rightEdge of rightEdges) { + if (rightEdge <= available) { + count++; + } else { + break; + } + } + setVisibleCount(count); + }; + + computeVisible(); + + const observer = new ResizeObserver(() => computeVisible()); + observer.observe(container); + for (const slot of slots) { + observer.observe(slot); + } + + return () => observer.disconnect(); + }, [totalItems]); + + return { visibleCount, containerRef, measureRef }; +} diff --git a/app/features/tournament/loaders/to.$id.info.server.ts b/app/features/tournament/loaders/to.$id.info.server.ts new file mode 100644 index 000000000..28ea524e3 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.info.server.ts @@ -0,0 +1,29 @@ +import type { LoaderFunctionArgs } from "react-router"; +import { getUser } from "~/features/auth/core/user.server"; +import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const user = getUser(); + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + + const description = + await TournamentRepository.findDescriptionById(tournamentId); + + if (!user) { + return { isSaved: false, description }; + } + + return { + isSaved: await SavedCalendarEventRepository.isSaved({ + userId: user.id, + tournamentId, + }), + description, + }; +}; diff --git a/app/features/tournament/loaders/to.$id.rules.server.ts b/app/features/tournament/loaders/to.$id.rules.server.ts new file mode 100644 index 000000000..e003b71c4 --- /dev/null +++ b/app/features/tournament/loaders/to.$id.rules.server.ts @@ -0,0 +1,15 @@ +import type { LoaderFunctionArgs } from "react-router"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { parseParams } from "~/utils/remix.server"; +import { idObject } from "~/utils/zod"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const { id: tournamentId } = parseParams({ + params, + schema: idObject, + }); + + return { + rules: await TournamentRepository.findRulesById(tournamentId), + }; +}; diff --git a/app/features/tournament/loaders/to.$id.seeds.server.ts b/app/features/tournament/loaders/to.$id.seeds.server.ts deleted file mode 100644 index f7f02c372..000000000 --- a/app/features/tournament/loaders/to.$id.seeds.server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { LoaderFunctionArgs } from "react-router"; -import { redirect } from "react-router"; -import { requireUser } from "~/features/auth/core/user.server"; -import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; -import { parseParams } from "~/utils/remix.server"; -import { tournamentBracketsPage } from "~/utils/urls"; -import { idObject } from "~/utils/zod"; - -export const loader = async ({ params }: LoaderFunctionArgs) => { - const user = requireUser(); - const { id: tournamentId } = parseParams({ - params, - schema: idObject, - }); - const tournament = await tournamentFromDB({ tournamentId, user }); - - if (!tournament.isOrganizer(user) || tournament.hasStarted) { - throw redirect(tournamentBracketsPage({ tournamentId })); - } - - return null; -}; diff --git a/app/features/tournament/routes/to.$id.admin.module.css b/app/features/tournament/routes/to.$id.admin.module.css deleted file mode 100644 index 1bf436ff8..000000000 --- a/app/features/tournament/routes/to.$id.admin.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.actionForm > :global(.flex-same-size) { - min-width: 10rem; -} diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx deleted file mode 100644 index 297d015c8..000000000 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ /dev/null @@ -1,862 +0,0 @@ -import clsx from "clsx"; -import { Trash } from "lucide-react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { useFetcher } from "react-router"; -import { Avatar } from "~/components/Avatar"; -import { Divider } from "~/components/Divider"; -import { LinkButton, SendouButton } from "~/components/elements/Button"; -import { SendouDialog } from "~/components/elements/Dialog"; -import { UserSearch } from "~/components/elements/UserSearch"; -import { FormMessage } from "~/components/FormMessage"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { Input } from "~/components/Input"; -import { Label } from "~/components/Label"; -import { containerClassName } from "~/components/Main"; -import { Redirect } from "~/components/Redirect"; -import { SubmitButton } from "~/components/SubmitButton"; -import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; -import { useUser } from "~/features/auth/core/user"; -import * as Progression from "~/features/tournament-bracket/core/Progression"; -import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server"; -import { USER } from "~/features/user-page/user-page-constants"; -import { databaseTimestampToDate } from "~/utils/dates"; -import invariant from "~/utils/invariant"; -import { assertUnreachable } from "~/utils/types"; -import { - calendarEventPage, - teamPage, - tournamentEditPage, - tournamentPage, -} from "~/utils/urls"; -import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector"; -import { useTournament } from "./to.$id"; -import adminStyles from "./to.$id.admin.module.css"; - -export { action } from "../actions/to.$id.admin.server"; - -export default function TournamentAdminPage() { - const { t } = useTranslation(["calendar"]); - const tournament = useTournament(); - const [editingProgression, setEditingProgression] = React.useState(false); - - const user = useUser(); - - // biome-ignore lint/correctness/useExhaustiveDependencies: we want to close the dialog after the progression was updated - React.useEffect(() => { - setEditingProgression(false); - }, [tournament]); - - if ( - !tournament.isOrganizer(user) || - (tournament.ctx.isFinalized && !DANGEROUS_CAN_ACCESS_DEV_CONTROLS) - ) { - return ; - } - - return ( -
- {tournament.isAdmin(user) && !tournament.hasStarted ? ( -
- - Edit event info - - {!tournament.isLeagueSignup ? ( - - - {t("calendar:actions.delete")} - - - ) : null} -
- ) : null} - {tournament.isAdmin(user) && - tournament.hasStarted && - !tournament.ctx.isFinalized ? ( -
- setEditingProgression(true)} - size="small" - variant="outlined" - data-testid="edit-event-info-button" - > - Edit brackets - - {editingProgression ? ( - setEditingProgression(false)} - /> - ) : null} -
- ) : null} - Team actions - - {tournament.isAdmin(user) ? ( - <> - Staff - - - ) : null} - Cast Twitch Accounts - - Participant list download - - {!tournament.isLeagueSignup ? ( - <> - Bracket reset - - - ) : null} - {DANGEROUS_CAN_ACCESS_DEV_CONTROLS && - tournament.ctx.isFinalized && - tournament.isAdmin(user) ? ( - <> - Reopen tournament (dev only) - - - ) : null} -
- ); -} - -type InputType = - | "TEAM_NAME" - | "REGISTERED_TEAM" - | "USER" - | "ROSTER_MEMBER" - | "BRACKET" - | "IN_GAME_NAME"; -const actions = [ - { - type: "ADD_TEAM", - inputs: ["USER", "TEAM_NAME"] as InputType[], - when: ["TOURNAMENT_BEFORE_START"], - }, - { - type: "CHANGE_TEAM_NAME", - inputs: ["REGISTERED_TEAM", "TEAM_NAME"] as InputType[], - when: [], - }, - { - type: "CHANGE_TEAM_OWNER", - inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[], - when: [], - }, - { - type: "CHECK_IN", - inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[], - when: ["CHECK_IN_STARTED"], - }, - { - type: "CHECK_OUT", - inputs: ["REGISTERED_TEAM", "BRACKET"] as InputType[], - when: ["CHECK_IN_STARTED"], - }, - { - type: "ADD_MEMBER", - inputs: ["USER", "REGISTERED_TEAM"] as InputType[], - when: [], - }, - { - type: "REMOVE_MEMBER", - inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM"] as InputType[], - when: [], - }, - { - type: "DELETE_TEAM", - inputs: ["REGISTERED_TEAM"] as InputType[], - when: ["TOURNAMENT_BEFORE_START"], - }, - { - type: "DROP_TEAM_OUT", - inputs: ["REGISTERED_TEAM"] as InputType[], - when: ["TOURNAMENT_AFTER_START"], - }, - { - type: "UNDO_DROP_TEAM_OUT", - inputs: ["REGISTERED_TEAM"] as InputType[], - when: ["TOURNAMENT_AFTER_START"], - }, - { - type: "UPDATE_IN_GAME_NAME", - inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as InputType[], - when: ["IN_GAME_NAME_REQUIRED"], - }, - { - type: "DELETE_LOGO", - inputs: ["REGISTERED_TEAM"] as InputType[], - when: [], - }, -] as const; - -function TeamActions() { - const fetcher = useFetcher(); - const { t } = useTranslation(["tournament"]); - const tournament = useTournament(); - const [selectedTeamId, setSelectedTeamId] = React.useState( - tournament.ctx.teams[0]?.id, - ); - const [selectedAction, setSelectedAction] = React.useState< - (typeof actions)[number] - >( - // if started, default to action with no restrictions - tournament.hasStarted - ? actions.find((a) => a.when.length === 0)! - : actions[0], - ); - - const selectedTeam = tournament.teamById(selectedTeamId); - - const actionsToShow = actions.filter((action) => { - for (const when of action.when) { - switch (when) { - case "CHECK_IN_STARTED": { - if (!tournament.regularCheckInStartInThePast) { - return false; - } - - break; - } - case "TOURNAMENT_BEFORE_START": { - if (tournament.hasStarted) { - return false; - } - - break; - } - case "TOURNAMENT_AFTER_START": { - if (!tournament.hasStarted) { - return false; - } - - break; - } - case "IN_GAME_NAME_REQUIRED": { - if (!tournament.ctx.settings.requireInGameNames) { - return false; - } - - break; - } - default: { - assertUnreachable(when); - } - } - } - - return true; - }); - - return ( -
- -
- - -
- {selectedAction.inputs.includes("REGISTERED_TEAM") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("TEAM_NAME") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( -
- - -
- ) : null} - {selectedAction.inputs.includes("USER") ? ( -
- -
- ) : null} - {selectedAction.inputs.includes("BRACKET") ? ( -
- - -
- ) : null} - {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( -
- -
- -
#
- -
-
- ) : null} - - Go - -
-
- ); -} - -function Staff() { - const tournament = useTournament(); - - return ( -
- {/* Key so inputs are cleared after staff is added */} - - -
- ); -} - -function CastTwitchAccounts() { - const id = React.useId(); - const fetcher = useFetcher(); - const tournament = useTournament(); - - return ( - -
-
- - -
- - Save - -
- - Twitch account where the tournament is casted. Player streams are added - automatically based on their profile data. You can also enter multiple - accounts, just separate them with a comma e.g. - "sendouc,leanny" - -
- ); -} - -function StaffAdder() { - const fetcher = useFetcher(); - - return ( - -
-
- -
-
-
- - -
- - Add - -
-
- - Organizer has same permissions as you expect adding/removing staff, - editing calendar event info and deleting the tournament. Streamer can - only talk in chats and see room password/pool. - -
- ); -} - -function StaffList() { - const { t } = useTranslation(["tournament"]); - const tournament = useTournament(); - - return ( -
- {tournament.ctx.staff.map((staff) => ( -
- {" "} -
-
{staff.username}
-
- {t(`tournament:staff.role.${staff.role}`)} -
-
- -
- ))} -
- ); -} - -function RemoveStaffButton({ - staff, -}: { - staff: TournamentData["ctx"]["staff"][number]; -}) { - const { t } = useTranslation(["tournament"]); - - return ( - - - - - - ); -} - -function DownloadParticipants() { - const tournament = useTournament(); - - function allParticipantsContent() { - return tournament.ctx.teams - .slice() - .sort((a, b) => a.name.localeCompare(b.name)) - .map((team) => { - const owner = team.members.find((user) => user.role === "OWNER"); - invariant(owner); - - const nonOwners = team.members.filter((user) => user.role !== "OWNER"); - - let result = `-- ${team.name} --\n(C) ${owner.username} (IGN: ${owner.inGameName ?? ""}) - <@${owner.discordId}>`; - - result += nonOwners - .map( - (user) => - `\n${user.username} (IGN: ${user.inGameName ?? ""}) - <@${user.discordId}>`, - ) - .join(""); - - result += "\n"; - - return result; - }) - .join("\n"); - } - - function checkedInParticipantsContent() { - const header = "Teams ordered by registration time\n---\n"; - - return ( - header + - tournament.ctx.teams - .slice() - .sort((a, b) => a.createdAt - b.createdAt) - .filter((team) => team.checkIns.length > 0) - .map((team, i) => { - return `${i + 1}) ${team.name} - ${databaseTimestampToDate( - team.createdAt, - ).toISOString()} - ${team.members - .map((member) => `${member.username} - <@${member.discordId}>`) - .join(" / ")}`; - }) - .join("\n") - ); - } - - function notCheckedInParticipantsContent() { - return tournament.ctx.teams - .slice() - .sort((a, b) => a.name.localeCompare(b.name)) - .filter((team) => team.checkIns.length === 0) - .map((team) => { - return `${team.name} - ${team.members - .map((member) => `${member.username} - <@${member.discordId}>`) - .join(" / ")}`; - }) - .join("\n"); - } - - function simpleListInSeededOrder() { - const hasCheckedInTeams = tournament.ctx.teams.some( - (team) => team.checkIns.length > 0, - ); - - return tournament.ctx.teams - .slice() - .sort( - (a, b) => - (a.seed ?? Number.POSITIVE_INFINITY) - - (b.seed ?? Number.POSITIVE_INFINITY), - ) - .filter((team) => !hasCheckedInTeams || team.checkIns.length > 0) - .map((team) => team.name) - .join("\n"); - } - - function leagueFormat() { - const memberColumnsCount = tournament.ctx.teams.reduce( - (max, team) => Math.max(max, team.members.length), - 0, - ); - const header = `Team id,Team name,Team page URL,Div${Array.from({ - length: memberColumnsCount, - }) - .map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`) - .join("")}`; - - return `${header}\n${tournament.ctx.teams - .map((team) => { - return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members - .map( - (member) => - `${member.username},https://sendou.ink/u/${member.discordId}`, - ) - .join(",")}${Array( - memberColumnsCount - team.members.length === 0 - ? 0 - : memberColumnsCount - team.members.length + 1, - ) - .fill(",") - .join("")}`; - }) - .join("\n")}`; - } - - return ( -
-
- - handleDownload({ - filename: "all-participants.txt", - content: allParticipantsContent(), - }) - } - > - All participants - - - handleDownload({ - filename: "checked-in-participants.txt", - content: checkedInParticipantsContent(), - }) - } - > - Checked in participants - - - handleDownload({ - filename: "not-checked-in-participants.txt", - content: notCheckedInParticipantsContent(), - }) - } - > - Not checked in participants - - - handleDownload({ - filename: "teams-in-seeded-order.txt", - content: simpleListInSeededOrder(), - }) - } - > - Simple list in seeded order - - {tournament.isLeagueSignup ? ( - - handleDownload({ - filename: "league-format.csv", - content: leagueFormat(), - }) - } - > - League format - - ) : null} -
-
- ); -} - -function handleDownload({ - content, - filename, -}: { - content: string; - filename: string; -}) { - const element = document.createElement("a"); - const file = new Blob([content], { - type: "text/plain", - }); - element.href = URL.createObjectURL(file); - element.download = filename; - document.body.appendChild(element); - element.click(); -} - -function BracketReset() { - const tournament = useTournament(); - const fetcher = useFetcher(); - const inProgressBrackets = tournament.brackets.filter((b) => !b.preview); - const [_bracketToDelete, setBracketToDelete] = React.useState( - inProgressBrackets[0]?.id, - ); - const [confirmText, setConfirmText] = React.useState(""); - - if (inProgressBrackets.length === 0) { - return
No brackets in progress
; - } - - const bracketToDelete = _bracketToDelete ?? inProgressBrackets[0].id; - - const bracketToDeleteName = inProgressBrackets.find( - (bracket) => bracket.id === bracketToDelete, - )?.name; - - return ( -
- -
- - -
-
- - setConfirmText(e.target.value)} - id="bracket-confirmation" - disableAutoComplete - /> -
- - Reset - -
- - Resetting a bracket will delete all the match results in it (but not - other brackets) and reset the bracket to its initial state allowing you - to change participating teams. - -
- ); -} - -function BracketProgressionEditDialog({ close }: { close: () => void }) { - const tournament = useTournament(); - const fetcher = useFetcher(); - const [bracketProgressionErrored, setBracketProgressionErrored] = - React.useState(false); - - const disabledBracketIdxs = tournament.brackets - .filter((bracket) => !bracket.preview) - .map((bracket) => bracket.idx); - - return ( - - - ({ - ...bracket, - disabled: disabledBracketIdxs.includes(idx), - }))} - isInvitationalTournament={tournament.isInvitational} - setErrored={setBracketProgressionErrored} - isTournamentInProgress - /> -
- - Save changes - -
-
-
- ); -} - -function ReopenTournament() { - const tournament = useTournament(); - const fetcher = useFetcher(); - const [confirmText, setConfirmText] = React.useState(""); - - return ( -
- -
- - setConfirmText(e.target.value)} - id="reopen-confirmation" - disableAutoComplete - /> -
- - Reopen - -
- - Reopening a tournament will delete all results, skill calculations, and - badges awarded from this tournament. Use this to test finalization - multiple times. - -
- ); -} diff --git a/app/features/tournament/routes/to.$id.index.ts b/app/features/tournament/routes/to.$id.index.ts index 4c5d2c1c0..61e10fd2d 100644 --- a/app/features/tournament/routes/to.$id.index.ts +++ b/app/features/tournament/routes/to.$id.index.ts @@ -3,7 +3,7 @@ import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tourn import { parseParams } from "~/utils/remix.server"; import { tournamentBracketsPage, - tournamentRegisterPage, + tournamentInfoPage, tournamentResultsPage, } from "~/utils/urls"; import { idObject } from "~/utils/zod"; @@ -20,7 +20,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { }); if (!tournament.hasStarted) { - return redirect(tournamentRegisterPage(tournamentId)); + return redirect(tournamentInfoPage(tournamentId)); } if (!tournament.ctx.isFinalized) { diff --git a/app/features/tournament/routes/to.$id.info.module.css b/app/features/tournament/routes/to.$id.info.module.css new file mode 100644 index 000000000..96d5f0b76 --- /dev/null +++ b/app/features/tournament/routes/to.$id.info.module.css @@ -0,0 +1,33 @@ +.description { + white-space: pre-wrap; + + & > :is(h1, h2, h3, h4, h5, h6) { + margin-block-end: var(--s-4); + } + + & > :is(h2, h3, h4, h5, h6) { + margin-block-start: var(--s-6); + } + + & > :first-child { + margin-block-start: 0; + } + + & > h1 { + font-size: var(--font-xl); + } + + & > :is(h2, h3, h4, h5, h6) { + font-size: var(--font-lg); + } + + & > :is(h3, h4, h5, h6) { + font-size: var(--font-md); + } +} + +.modes { + display: inline-flex; + gap: var(--s-1); + flex-wrap: wrap; +} diff --git a/app/features/tournament/routes/to.$id.info.tsx b/app/features/tournament/routes/to.$id.info.tsx new file mode 100644 index 000000000..95d22ed91 --- /dev/null +++ b/app/features/tournament/routes/to.$id.info.tsx @@ -0,0 +1,140 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import type { MetaFunction } from "react-router"; +import { useLoaderData } from "react-router"; +import { ModeImage } from "~/components/Image"; +import { containerClassName } from "~/components/Main"; +import { Markdown } from "~/components/Markdown"; +import { TierPill } from "~/components/TierPill"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import type { TournamentData } from "~/features/tournament-bracket/core/Tournament.server"; +import { metaTags } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { removeMarkdown } from "~/utils/strings"; +import { tournamentPage } from "~/utils/urls"; +import { FactCardGrid, type FactCardItem } from "../components/FactCard"; +import { RegistrationActions } from "../components/RegistrationActions"; +import { + TournamentHeader, + TournamentHeaderActions, +} from "../components/TournamentHeader"; +import { loader } from "../loaders/to.$id.info.server"; +import { bracketProgressionLabel } from "../tournament-utils"; +import { useTournament } from "./to.$id"; +import styles from "./to.$id.info.module.css"; + +export { loader }; + +export const meta: MetaFunction = (args) => { + const tournamentData = JSON.parse(args.matches[1].data as any)?.tournament as + | TournamentData + | undefined; + if (!tournamentData) return []; + + return metaTags({ + title: tournamentData.ctx.name, + description: args.data?.description + ? removeMarkdown(args.data.description) + : undefined, + image: { + url: tournamentData.ctx.logoUrl, + dimensions: { width: 124, height: 124 }, + }, + location: args.location, + url: tournamentPage(tournamentData.ctx.id), + }); +}; + +export const handle: SendouRouteHandle = { + i18n: ["tournament"], +}; + +export default function TournamentInfoPage() { + const tournament = useTournament(); + const data = useLoaderData(); + const facts = useFacts(tournament); + + return ( +
+ +
+ + +
+ + {data.description ? ( +
+ {data.description} +
+ ) : null} +
+ ); +} + +function useFacts( + tournament: ReturnType, +): FactCardItem[] { + const { t } = useTranslation(["tournament"]); + + const teamSizeValue = + tournament.minMembersPerTeam === tournament.maxMembersPerTeam + ? `${tournament.minMembersPerTeam}` + : `${tournament.minMembersPerTeam}–${tournament.maxMembersPerTeam}`; + + const showsEstimatedTier = !tournament.ctx.tier && !tournament.hasStarted; + + const rankedSeason = Seasons.current(tournament.ctx.startTime); + + return [ + { + label: t("tournament:fact.format"), + value: `${tournament.minMembersPerTeam}v${tournament.minMembersPerTeam}`, + }, + { + label: t("tournament:fact.bracket"), + value: bracketProgressionLabel( + tournament.ctx.settings.bracketProgression, + ), + }, + { + label: t("tournament:fact.modes"), + value: ( +
+ {tournament.modesIncluded.map((mode) => ( + + ))} +
+ ), + }, + { + label: showsEstimatedTier + ? t("tournament:fact.tier.est") + : t("tournament:fact.tier"), + value: tournament.ctx.tier ? ( + + ) : showsEstimatedTier && tournament.ctx.tentativeTier ? ( + + ) : ( + "-" + ), + }, + { + label: t("tournament:fact.ranked"), + value: + tournament.ranked && rankedSeason + ? t("tournament:fact.ranked.yesWithSeason", { + season: rankedSeason.nth, + }) + : tournament.ranked + ? t("tournament:fact.ranked.yes") + : t("tournament:fact.ranked.no"), + }, + { + label: t("tournament:fact.teamSize"), + value: teamSizeValue, + }, + ]; +} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 7b772412d..29a234dda 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -1,74 +1,45 @@ import clsx from "clsx"; -import Compressor from "compressorjs"; -import { - AlertCircle, - Bookmark, - BookmarkCheck, - Check, - Clock, - Share2, - Trash, - User, - X, -} from "lucide-react"; +import { AlertCircle, Check, X } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Form, Link, useFetcher, useLoaderData } from "react-router"; +import { useFetcher, useLoaderData } from "react-router"; import { useCopyToClipboard } from "react-use"; import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; -import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover"; import { Divider } from "~/components/Divider"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { - SendouTab, - SendouTabList, - SendouTabPanel, - SendouTabs, -} from "~/components/elements/Tabs"; import { FormWithConfirm } from "~/components/FormWithConfirm"; import { FriendCodePopover } from "~/components/FriendCodePopover"; -import { Image, ModeImage } from "~/components/Image"; -import { Input } from "~/components/Input"; -import { DiscordIcon } from "~/components/icons/Discord"; import { Label } from "~/components/Label"; import { containerClassName } from "~/components/Main"; -import { MapPoolStages } from "~/components/MapPoolSelector"; -import { Markdown } from "~/components/Markdown"; -import { Section } from "~/components/Section"; import { SubmitButton } from "~/components/SubmitButton"; -import { TierPill } from "~/components/TierPill"; -import TimePopover from "~/components/TimePopover"; import { useUser } from "~/features/auth/core/user"; -import { imgTypeToDimensions } from "~/features/img-upload/upload-constants"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; +import { FormField } from "~/form/FormField"; +import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useHydrated } from "~/hooks/useHydrated"; -import { useSearchParamState } from "~/hooks/useSearchParamState"; -import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; import invariant from "~/utils/invariant"; -import { logger } from "~/utils/logger"; import { LOG_IN_URL, - mapsPageWithMapPool, - navIconUrl, SENDOU_INK_BASE_URL, tournamentJoinPage, - tournamentOrganizationPage, - tournamentPage, - tournamentSubsPage, userEditProfilePage, - userPage, } from "~/utils/urls"; import { action } from "../actions/to.$id.register.server"; import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server"; import { loader } from "../loaders/to.$id.register.server"; import styles from "../tournament.module.css"; import { TOURNAMENT } from "../tournament-constants"; +import { + type RegisterTeamFormValues, + registerTeamFormSchema, +} from "../tournament-register-schemas"; import { type CounterPickValidationStatus, validateCounterPickMapPool, @@ -78,101 +49,6 @@ import { useTournament } from "./to.$id"; export { action, loader }; export default function TournamentRegisterPage() { - const isHydrated = useHydrated(); - const tournament = useTournament(); - - return ( -
-
- -
-
{tournament.ctx.name}
-
- {tournament.ctx.organization ? ( - - - {tournament.ctx.organization.name} - - ) : ( - - {" "} - {tournament.ctx.author.username} - - )} -
- {!tournament.isLeagueSignup ? ( -
-
- {" "} - {isHydrated ? ( - - ) : null} -
-
- ) : null} -
- {tournament.ranked ? ( -
- Ranked -
- ) : ( -
- Unranked -
- )} - {tournament.ctx.tier ? ( - - ) : tournament.ctx.tentativeTier && !tournament.hasStarted ? ( - - ) : null} -
- {tournament.modesIncluded.map((mode) => ( - - ))} -
-
-
-
- -
- ); -} - -const TABS = ["description", "rules", "register"] as const; -type RegisterPageTab = (typeof TABS)[number]; - -function TournamentRegisterInfoTabs() { const user = useUser(); const tournament = useTournament(); const { t } = useTranslation(["tournament"]); @@ -180,18 +56,8 @@ function TournamentRegisterInfoTabs() { const teamMemberOf = tournament.teamMemberOfByUser(user); const teamOwned = tournament.ownedTeamByUser(user); const isRegularMemberOfATeam = teamMemberOf && !teamOwned; - - const defaultTab = (): RegisterPageTab => { - if (tournament.hasStarted || !teamOwned) return "description"; - - return "register"; - }; - const [tabKey, setTabKey] = useSearchParamState({ - defaultValue: defaultTab(), - name: "tab", - revive: (val) => - TABS.includes(val as RegisterPageTab) ? (val as RegisterPageTab) : null, - }); + const registrationClosedForNonParticipant = + !tournament.registrationOpen && !teamMemberOf; const showAddIGNAlert = tournament.ctx.settings.requireInGameNames && @@ -200,97 +66,28 @@ function TournamentRegisterInfoTabs() { !user?.inGameName; return ( -
- setTabKey(key as RegisterPageTab)} - > - - Description - {tournament.ctx.rules ? ( - Rules - ) : null} - {!tournament.hasStarted ? ( - - Register - - ) : null} - - - -
-
- {tournament.ctx.discordUrl ? ( -
- } - > - Join the Discord - -
- ) : null} - - +
+ {isRegularMemberOfATeam ? ( +
+ {t("tournament:pre.inATeam")} + +
+ ) : registrationClosedForNonParticipant ? ( + {t("tournament:pre.registrationClosed")} + ) : showAddIGNAlert ? ( +
+ +
+ This tournament requires you to have an in-game name set{" "} + + Edit profile +
- -
- {tournament.ctx.description ?? ""} -
- - -
- - - {tournament.ctx.rules ? ( - -
- {tournament.ctx.rules ?? ""} -
-
- ) : null} - - {!tournament.hasStarted ? ( - -
- {isRegularMemberOfATeam ? ( -
- {t("tournament:pre.inATeam")} - -
- ) : showAddIGNAlert ? ( -
- -
- This tournament requires you to have an in-game name set{" "} - - Edit profile - -
-
-
- ) : ( - - )} - {user && - !tournament.teamMemberOfByUser(user) && - tournament.canAddNewSubPost && - !showAddIGNAlert && - !tournament.hasStarted ? ( - - {t("tournament:pre.sub.prompt")} - - ) : null} -
-
- ) : null} - + +
+ ) : ( + + )}
); } @@ -625,71 +422,25 @@ function TeamInfo({ ownTeam?: TournamentDataTeam | null; canUnregister: boolean; }) { - const data = useLoaderData(); const { t } = useTranslation(["tournament", "common"]); - const fetcher = useFetcher(); const tournament = useTournament(); - const [teamName, setTeamName] = React.useState(ownTeam?.name ?? ""); - const user = useUser(); - const ref = React.useRef(null); - const [signUpWithTeamId, setSignUpWithTeamId] = React.useState( - () => tournament.ownedTeamByUser(user)?.team?.id ?? null, - ); - const [uploadedAvatar, setUploadedAvatar] = React.useState(null); - const handleSignUpWithTeamChange = (teamId: number | null) => { - if (!teamId) { - setSignUpWithTeamId(null); - } else { - setSignUpWithTeamId(teamId); - const teamName = data?.teams.find((team) => team.id === teamId)?.name; - invariant(teamName, "team name should exist"); - - setTeamName(teamName); - } + const defaultValues: Partial = { + teamId: ownTeam?.team ? String(ownTeam.team.id) : null, + pickUpName: ownTeam?.team ? null : (ownTeam?.name ?? ""), + logo: + !ownTeam?.team && + ownTeam?.pickupAvatarUrl && + typeof ownTeam?.avatarImgId === "number" + ? { + type: "EXISTING", + imgId: ownTeam.avatarImgId, + url: ownTeam.pickupAvatarUrl, + } + : null, + prefersNotToHost: Boolean(ownTeam?.prefersNotToHost), }; - 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 (signUpWithTeamId) { - const teamToSignUpWith = data?.teams.find( - (team) => team.id === signUpWithTeamId, - ); - return teamToSignUpWith?.logoUrl; - } - if (uploadedAvatar) return URL.createObjectURL(uploadedAvatar); - - return ownTeam?.pickupAvatarUrl; - })(); - - const canEditAvatar = - tournament.registrationOpen && - !signUpWithTeamId && - uploadedAvatar && - !ownTeam?.pickupAvatarUrl; - - const canDeleteAvatar = ownTeam?.pickupAvatarUrl; - return (
@@ -730,161 +481,55 @@ function TeamInfo({ ) : null}
- - - {signUpWithTeamId ? ( - - ) : null} -
- {data && data.teams.length > 0 && tournament.registrationOpen ? ( -
- - -
- ) : null} - - {!signUpWithTeamId ? ( -
- - setTeamName(e.target.value)} - readOnly={ - !tournament.registrationOpen || Boolean(signUpWithTeamId) - } - /> -
- ) : ( - - )} - {tournament.registrationOpen || avatarUrl ? ( -
- - {avatarUrl ? ( -
- - {canEditAvatar ? ( - setUploadedAvatar(null)} - > - {t("common:actions.edit")} - - ) : 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; - } +function RegisterTeamFields() { + const data = useLoaderData(); + const tournament = useTournament(); + const { values } = useFormFieldContext(); - 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) { - logger.error(err.message); - }, - }); - }} - /> + const isLinked = Boolean(values.teamId); + + const teamOptions = (data?.teams ?? []).map((team) => ({ + value: String(team.id), + label: team.name, + })); + const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen; + + return ( + <> + {showTeamSelect ? ( +
+ +
+ ) : null} + {!isLinked ? ( + <> +
+ +
+
+ +
+ + ) : null} + + ); } @@ -1308,120 +953,3 @@ function MapPoolValidationStatusMessage({
); } - -function SaveTournamentButton() { - const { t } = useTranslation(["common"]); - const user = useUser(); - const tournament = useTournament(); - const data = useLoaderData(); - const fetcher = useFetcher(); - - const teamMemberOf = tournament.teamMemberOfByUser(user); - if (!user || tournament.hasStarted || teamMemberOf) return null; - - const isSaved = - fetcher.formData?.get("_action") === "SAVE_TOURNAMENT" - ? true - : fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT" - ? false - : (data?.isSaved ?? false); - - return ( - - - : } - > - {isSaved ? t("common:actions.unsave") : t("common:actions.save")} - - - ); -} - -function ShareTournamentButton() { - const { t } = useTranslation(["common"]); - const tournament = useTournament(); - - const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`; - - const handleShare = () => { - navigator.share({ url }); - }; - - if ( - typeof navigator !== "undefined" && - typeof navigator.share === "function" - ) { - return ( - } - onPress={handleShare} - > - {t("common:actions.share")} - - ); - } - - return ( - }> - {t("common:actions.share")} - - } - /> - ); -} - -function TOPickedMapPoolInfo() { - const { t } = useTranslation(["calendar"]); - const tournament = useTournament(); - - if (tournament.ctx.toSetMapPool.length === 0) return null; - - const mapPool = new MapPool(tournament.ctx.toSetMapPool); - - return ( -
-
- -
- - - {t("calendar:createMapList")} - -
-
-
- ); -} - -function TiebreakerMapPoolInfo() { - const { t } = useTranslation(["game-misc"]); - const tournament = useTournament(); - - if (tournament.ctx.tieBreakerMapPool.length === 0) return null; - - return ( -
- Tiebreaker map pool:{" "} - {tournament.ctx.tieBreakerMapPool - .sort((a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode)) - .map( - (map) => - `${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`, - ) - .join(", ")} -
- ); -} diff --git a/app/features/tournament/routes/to.$id.rules.tsx b/app/features/tournament/routes/to.$id.rules.tsx new file mode 100644 index 000000000..75f1da183 --- /dev/null +++ b/app/features/tournament/routes/to.$id.rules.tsx @@ -0,0 +1,81 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { useLoaderData } from "react-router"; +import { LinkButton } from "~/components/elements/Button"; +import { Image } from "~/components/Image"; +import { containerClassName } from "~/components/Main"; +import { MapPoolStages } from "~/components/MapPoolSelector"; +import { Markdown } from "~/components/Markdown"; +import { Section } from "~/components/Section"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls"; +import { loader } from "../loaders/to.$id.rules.server"; +import { useTournament } from "./to.$id"; +import styles from "./to.$id.info.module.css"; + +export { loader }; + +export const handle: SendouRouteHandle = { + i18n: ["tournament", "calendar", "game-misc"], +}; + +export default function TournamentRulesPage() { + const { rules } = useLoaderData(); + + return ( +
+ {rules ? ( +
+ {rules} +
+ ) : null} + + +
+ ); +} + +function CounterPickMapPool() { + const { t } = useTranslation(["calendar"]); + const tournament = useTournament(); + + if (tournament.ctx.toSetMapPool.length === 0) return null; + + const mapPool = new MapPool(tournament.ctx.toSetMapPool); + + return ( +
+
+ +
+ + + {t("calendar:createMapList")} + +
+
+
+ ); +} + +function TiebreakerMapPool() { + const { t } = useTranslation(["game-misc"]); + const tournament = useTournament(); + + if (tournament.ctx.tieBreakerMapPool.length === 0) return null; + + return ( +
+ Tiebreaker map pool:{" "} + {tournament.ctx.tieBreakerMapPool + .sort((a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode)) + .map( + (map) => + `${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`, + ) + .join(", ")} +
+ ); +} diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index c7fbdbb0c..7c54a0b0c 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -1,42 +1,34 @@ import * as React from "react"; -import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { Outlet, type ShouldRevalidateFunction, useLoaderData, + useMatches, useOutletContext, } from "react-router"; -import { Main } from "~/components/Main"; +import { containerClassName, Main } from "~/components/Main"; import { Placeholder } from "~/components/Placeholder"; -import { SubNav, SubNavLink } from "~/components/SubNav"; -import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls"; -import { useUser } from "~/features/auth/core/user"; import { useChatContext } from "~/features/chat/useChatContext"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { useHydrated } from "~/hooks/useHydrated"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { removeMarkdown } from "~/utils/strings"; -import { - tournamentDivisionsPage, - tournamentPage, - tournamentRegisterPage, -} from "~/utils/urls"; -import { metaTags } from "../../../utils/remix"; +import { tournamentPage } from "~/utils/urls"; +import { isRevalidation, metaTags } from "../../../utils/remix"; +import { TournamentNav } from "../components/TournamentNav"; import { loader, type TournamentLoaderData } from "../loaders/to.$id.server"; export { loader }; export const shouldRevalidate: ShouldRevalidateFunction = (args) => { - const navigatedToMatchPage = - typeof args.nextParams.mid === "string" && - args.formMethod !== "POST" && - args.currentParams.mid !== args.nextParams.mid; + if (isRevalidation(args)) return args.defaultShouldRevalidate; + if (args.formMethod === "POST") return args.defaultShouldRevalidate; + if (args.currentParams.id !== args.nextParams.id) { + return args.defaultShouldRevalidate; + } - if (navigatedToMatchPage) return false; - - return args.defaultShouldRevalidate; + return false; }; export const meta: MetaFunction = (args) => { @@ -48,9 +40,6 @@ export const meta: MetaFunction = (args) => { return metaTags({ title: data.tournament.ctx.name, - description: data.tournament.ctx.description - ? removeMarkdown(data.tournament.ctx.description) - : undefined, image: { url: data.tournament.ctx.logoUrl, dimensions: { width: 124, height: 124 }, @@ -99,8 +88,6 @@ export default function TournamentLayoutShell() { } export function TournamentLayout() { - const { t } = useTranslation(["tournament"]); - const user = useUser(); const rawData = useLoaderData(); const data = React.useMemo( () => JSON.parse(rawData) as TournamentLoaderData, @@ -111,6 +98,7 @@ export function TournamentLayout() { [data], ); const [bracketExpanded, setBracketExpanded] = React.useState(true); + const mainBreakout = useActiveRouteMainBreakout(); useTournamentChatLabels(tournament); @@ -122,87 +110,12 @@ export function TournamentLayout() { window.tourney = tournament; }, [tournament]); } - return ( -
- - - {tournament.hasStarted || tournament.isLeagueDivision - ? "Info" - : t("tournament:tabs.register")} - - {!tournament.isLeagueSignup ? ( - - {t("tournament:tabs.brackets")} - - ) : null} - {tournament.isLeagueSignup || tournament.isLeagueDivision ? ( - - Divisions - - ) : null} - {!(tournament.isLeagueSignup && data.hasChildTournaments) ? ( - - {t("tournament:tabs.teams", { - count: tournament.ctx.teams.length, - })} - - ) : null} - {!tournament.isInvitational && - !tournament.everyBracketOver && - !(tournament.isLeagueSignup && !tournament.registrationOpen) && - tournament.lfgEnabled ? ( - - {tournament.registrationOpen - ? t("tournament:tabs.looking") - : t("tournament:tabs.subs")} - - ) : null} - {tournament.hasStarted && !tournament.everyBracketOver ? ( - - {t("tournament:tabs.streams", { - count: tournament.streams.length, - })} - - ) : null} - {tournament.hasStarted ? ( - - {t("tournament:tabs.results")} - - ) : null} - {tournament.isOrganizer(user) && - !tournament.hasStarted && - !tournament.isLeagueSignup && ( - {t("tournament:tabs.seeds")} - )} - {tournament.isOrganizer(user) && - (!tournament.ctx.isFinalized || - DANGEROUS_CAN_ACCESS_DEV_CONTROLS) && ( - - {t("tournament:tabs.admin")} - - )} - + const content = ( + <> + + + ); + + return ( +
+ {mainBreakout ? ( +
{content}
+ ) : ( + content + )}
); } +function useActiveRouteMainBreakout(): boolean { + const matches = useMatches(); + + return matches.some( + (match) => (match.handle as SendouRouteHandle | undefined)?.mainBreakout, + ); +} + type TournamentContext = { tournament: Tournament; bracketExpanded: boolean; diff --git a/app/features/tournament/routes/to.search.ts b/app/features/tournament/routes/to.search.ts index 4f9ffc23b..95814b3c6 100644 --- a/app/features/tournament/routes/to.search.ts +++ b/app/features/tournament/routes/to.search.ts @@ -17,6 +17,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { q: query, limit, minStartTime, + maxStartTime, } = parseSearchParams({ request, schema: tournamentSearchSearchParamsSchema, @@ -29,6 +30,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { query, limit, minStartTime, + maxStartTime, }), query, }; diff --git a/app/features/tournament/tournament-register-schemas.server.ts b/app/features/tournament/tournament-register-schemas.server.ts new file mode 100644 index 000000000..17e793afd --- /dev/null +++ b/app/features/tournament/tournament-register-schemas.server.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { registerTeamFormSchema } from "./tournament-register-schemas"; +import { tournamentTeamNameTaken } from "./tournament-utils.server"; + +/** + * Extends the client {@link registerTeamFormSchema} with the server-only unique team + * name check, surfaced as a field error. Shares the uniqueness rule with the admin + * registration form ({@link adminRegistrationFormSchemaServer}) via + * {@link tournamentTeamNameTaken}. + */ +export function registerTeamFormSchemaServer({ + tournament, + ownTeamId, +}: { + tournament: Tournament; + /** The team the registering user already owns, excluded from the uniqueness check. */ + ownTeamId?: number; +}) { + return registerTeamFormSchema.superRefine(async (data, ctx) => { + const linkedTeamId = data.teamId ? Number(data.teamId) : null; + const name = linkedTeamId + ? (await TeamRepository.findById(linkedTeamId))?.name + : data.pickUpName; + if (!name) return; + + if ( + tournamentTeamNameTaken({ + tournament, + name, + exceptTournamentTeamId: ownTeamId, + }) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regTeamNameTaken", + path: [linkedTeamId ? "teamId" : "pickUpName"], + }); + } + }); +} diff --git a/app/features/tournament/tournament-register-schemas.ts b/app/features/tournament/tournament-register-schemas.ts new file mode 100644 index 000000000..d05d0df6f --- /dev/null +++ b/app/features/tournament/tournament-register-schemas.ts @@ -0,0 +1,34 @@ +import { z } from "zod"; +import { + image, + selectDynamicOptional, + stringConstant, + textFieldOptional, + toggle, +} from "~/form/fields"; +import { TOURNAMENT } from "./tournament-constants"; + +export const registerTeamFormSchema = z + .object({ + _action: stringConstant("UPSERT_TEAM"), + /** `String(teamId)` of one of the user's sendou.ink teams, or null for a pickup team. */ + teamId: selectDynamicOptional({ label: "labels.regSignUpAs" }), + pickUpName: textFieldOptional({ + label: "labels.regPickUpName", + maxLength: TOURNAMENT.TEAM_NAME_MAX_LENGTH, + }), + /** Pickup team logo. Linked teams source their logo from the sendou.ink team instead. */ + logo: image({ label: "labels.logo" }), + prefersNotToHost: toggle({ label: "labels.regPrefersNotToHost" }), + }) + .superRefine((data, ctx) => { + if (!data.teamId && !data.pickUpName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.regTeamNameRequired", + path: ["pickUpName"], + }); + } + }); + +export type RegisterTeamFormValues = z.input; diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 92abdff25..dc4599627 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -1,195 +1,53 @@ import { z } from "zod"; -import { - _action, - checkboxValueToBoolean, - id, - modeShort, - optionalId, - safeJSONParse, - safeStringSchema, - stageId, -} from "~/utils/zod"; -import { bracketProgressionSchema } from "../calendar/calendar-schemas"; -import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; -import { USER } from "../user-page/user-page-constants"; -import { TOURNAMENT } from "./tournament-constants"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod"; +import { registerTeamFormSchemaServer } from "./tournament-register-schemas.server"; -const teamName = safeStringSchema({ - max: TOURNAMENT.TEAM_NAME_MAX_LENGTH, -}); - -export const registerSchema = z.union([ - z.object({ - _action: _action("UPSERT_TEAM"), - teamName, - prefersNotToHost: z.preprocess(checkboxValueToBoolean, z.boolean()), - teamId: optionalId, - }), - z.object({ - _action: _action("UPDATE_MAP_POOL"), - mapPool: z.preprocess( - safeJSONParse, - z.array(z.object({ stageId, mode: modeShort })), - ), - }), - z.object({ - _action: _action("DELETE_TEAM_MEMBER"), - userId: id, - }), - z.object({ - _action: _action("LEAVE_TEAM"), - }), - z.object({ - _action: _action("CHECK_IN"), - }), - z.object({ - _action: _action("ADD_PLAYER"), - userId: id, - }), - z.object({ - _action: _action("UNREGISTER"), - }), - z.object({ - _action: _action("DELETE_LOGO"), - }), - z.object({ - _action: _action("SAVE_TOURNAMENT"), - }), - z.object({ - _action: _action("UNSAVE_TOURNAMENT"), - }), -]); - -export const seedsActionSchema = z.union([ - z.object({ - _action: _action("UPDATE_SEEDS"), - seeds: z.preprocess(safeJSONParse, z.array(id)), - }), - z.object({ - _action: _action("UPDATE_STARTING_BRACKETS"), - startingBrackets: z.preprocess( - safeJSONParse, - z.array( - z.object({ - tournamentTeamId: id, - startingBracketIdx: bracketIdx, - }), +export function registerSchema({ + tournament, + ownTeamId, +}: { + tournament: Tournament; + ownTeamId?: number; +}) { + return z.union([ + registerTeamFormSchemaServer({ tournament, ownTeamId }), + z.object({ + _action: _action("UPDATE_MAP_POOL"), + mapPool: z.preprocess( + safeJSONParse, + z.array(z.object({ stageId, mode: modeShort })), ), - ), - }), - z.object({ - _action: _action("UPDATE_AB_DIVISIONS"), - abDivisions: z.preprocess( - safeJSONParse, - z.array( - z.object({ - tournamentTeamId: id, - abDivision: z.union([z.literal(0), z.literal(1), z.null()]), - }), - ), - ), - }), -]); + }), + z.object({ + _action: _action("DELETE_TEAM_MEMBER"), + userId: id, + }), + z.object({ + _action: _action("LEAVE_TEAM"), + }), + z.object({ + _action: _action("CHECK_IN"), + }), + z.object({ + _action: _action("ADD_PLAYER"), + userId: id, + }), + z.object({ + _action: _action("UNREGISTER"), + }), + z.object({ + _action: _action("SAVE_TOURNAMENT"), + }), + z.object({ + _action: _action("UNSAVE_TOURNAMENT"), + }), + ]); +} export const tournamentSearchSearchParamsSchema = z.object({ q: z.string().max(100), limit: z.coerce.number().int().min(1).max(25).catch(25), minStartTime: z.coerce.date().optional().catch(undefined), + maxStartTime: z.coerce.date().optional().catch(undefined), }); - -export const adminActionSchema = z.union([ - z.object({ - _action: _action("CHANGE_TEAM_OWNER"), - teamId: id, - memberId: id, - }), - z.object({ - _action: _action("CHANGE_TEAM_NAME"), - teamId: id, - teamName, - }), - z.object({ - _action: _action("CHECK_IN"), - teamId: id, - bracketIdx, - }), - z.object({ - _action: _action("CHECK_OUT"), - teamId: id, - bracketIdx, - }), - z.object({ - _action: _action("ADD_MEMBER"), - teamId: id, - userId: id, - }), - z.object({ - _action: _action("REMOVE_MEMBER"), - teamId: id, - memberId: id, - }), - z.object({ - _action: _action("DELETE_TEAM"), - teamId: id, - }), - z.object({ - _action: _action("ADD_TEAM"), - userId: id, - teamName, - }), - z.object({ - _action: _action("ADD_STAFF"), - userId: id, - role: z.enum(["ORGANIZER", "STREAMER"]), - }), - z.object({ - _action: _action("REMOVE_STAFF"), - userId: id, - }), - z.object({ - _action: _action("DROP_TEAM_OUT"), - teamId: id, - }), - z.object({ - _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( - (val) => - typeof val === "string" - ? val - .split(",") - .map((account) => account.trim()) - .map((account) => account.toLowerCase()) - : val, - z.array(z.string()), - ), - }), - z.object({ - _action: _action("RESET_BRACKET"), - stageId: id, - }), - z.object({ - _action: _action("UPDATE_IN_GAME_NAME"), - inGameNameText: z - .string() - .refine((val) => [...val].length <= USER.IN_GAME_NAME_TEXT_MAX_LENGTH), - inGameNameDiscriminator: z - .string() - .refine((val) => /^[0-9a-z]{4,5}$/.test(val)), - memberId: id, - }), - z.object({ - _action: _action("UPDATE_TOURNAMENT_PROGRESSION"), - bracketProgression: bracketProgressionSchema, - }), - z.object({ - _action: _action("REOPEN_TOURNAMENT"), - }), -]); diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index 8a903b025..f2f5a756d 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -1,6 +1,7 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { databaseTimestampNow } from "~/utils/dates"; import invariant from "~/utils/invariant"; +import { withUserId } from "~/utils/Test"; import { getServerTournamentManager } from "../tournament-bracket/core/brackets-manager/manager.server"; import { tournamentFromDB } from "../tournament-bracket/core/Tournament.server"; import { updateRoundMaps } from "./queries/updateRoundMaps.server"; @@ -55,26 +56,32 @@ export async function dbInsertTournamentTeam({ /** Id of the tournament to associate the team with. Defaults to 1. */ tournamentId?: number; }) { - const tournamentTeam = await TournamentTeamRepository.create({ - team: { - name: `Test Team ${ownerId}`, - prefersNotToHost: 0, - teamId: null, - }, - userId: ownerId, - tournamentId, - }); + const tournamentTeam = await withUserId(ownerId, () => + TournamentTeamRepository.create({ + team: { + name: `Test Team ${ownerId}`, + prefersNotToHost: 0, + teamId: null, + }, + userId: ownerId, + tournamentId, + }), + ); for (let i = 1; i < membersCount; i++) { const memberId = ownerId + i; - await TournamentTeamRepository.join({ - userId: memberId, - newTeamId: tournamentTeam.id, - }); + await withUserId(memberId, () => + TournamentTeamRepository.join({ + userId: memberId, + newTeamId: tournamentTeam.id, + }), + ); } - await TournamentTeamRepository.checkIn(tournamentTeam.id); + await withUserId(ownerId, () => + TournamentTeamRepository.checkIn(tournamentTeam.id), + ); } /** diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 2629f0411..2a2e1263c 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -28,6 +28,28 @@ export async function requireNotBannedByOrganization({ } } +/** + * Whether the given team name is already used by another team in the tournament. + * Single source of truth for the uniqueness rule shared by the player registration + * ({@link registerTeamFormSchemaServer}) and admin registration + * ({@link adminRegistrationFormSchemaServer}) forms. + * + * @param exceptTournamentTeamId - the team being edited, excluded from the comparison + */ +export function tournamentTeamNameTaken({ + tournament, + name, + exceptTournamentTeamId, +}: { + tournament: Tournament; + name: string; + exceptTournamentTeamId?: number; +}) { + return tournament.ctx.teams.some( + (team) => team.name === name && team.id !== exceptTournamentTeamId, + ); +} + export async function requireSendouQParticipationIfNeeded({ tournament, userId, diff --git a/app/features/tournament/tournament-utils.test.ts b/app/features/tournament/tournament-utils.test.ts index 40e8a2b47..a201a89e6 100644 --- a/app/features/tournament/tournament-utils.test.ts +++ b/app/features/tournament/tournament-utils.test.ts @@ -3,10 +3,12 @@ import type { CastedMatchesInfo } from "~/db/tables"; import * as Seasons from "../mmr/core/Seasons"; import type { ParsedBracket } from "../tournament-bracket/core/Progression"; import { + bracketProgressionLabel, compareTeamsForOrdering, findTeamInsertPosition, getBracketProgressionLabel, sortTeamsBySeeding, + splitTournamentName, type TeamForOrdering, tournamentInWeaponReportingWindow, updatedCastedMatchesInfo, @@ -684,3 +686,103 @@ describe("tournamentInWeaponReportingWindow", () => { ).toBe(true); }); }); + +describe("splitTournamentName", () => { + const series = [{ name: "In The Zone" }, { name: "Low Ink" }]; + + it("splits the trailing number subtext after the series name", () => { + expect(splitTournamentName("In The Zone 54", series)).toEqual({ + name: "In The Zone", + subtext: "54", + }); + }); + + it("splits a non-numeric subtext after the series name", () => { + expect(splitTournamentName("Low Ink May 2026", series)).toEqual({ + name: "Low Ink", + subtext: "May 2026", + }); + }); + + it("matches the series name case-insensitively", () => { + expect(splitTournamentName("in the zone 54", series)).toEqual({ + name: "In The Zone", + subtext: "54", + }); + }); + + it("strips separators between the series name and the subtext", () => { + expect(splitTournamentName("In The Zone - 54", series)).toEqual({ + name: "In The Zone", + subtext: "54", + }); + }); + + it("trims trailing whitespace after the subtext", () => { + expect(splitTournamentName("In The Zone 54 ", series)).toEqual({ + name: "In The Zone", + subtext: "54", + }); + }); + + it("returns name only when the name does not start with a series name", () => { + expect(splitTournamentName("Picnic Weekly", series)).toEqual({ + name: "Picnic Weekly", + }); + }); + + it("returns name only when the name equals the series name", () => { + expect(splitTournamentName("In The Zone", series)).toEqual({ + name: "In The Zone", + }); + }); + + it("returns name only when there are no series", () => { + expect(splitTournamentName("In The Zone 54", [])).toEqual({ + name: "In The Zone 54", + }); + }); + + it("prefers the longest matching series name", () => { + expect( + splitTournamentName("In The Zone Masters 5", [ + { name: "In The Zone" }, + { name: "In The Zone Masters" }, + ]), + ).toEqual({ + name: "In The Zone Masters", + subtext: "5", + }); + }); +}); + +describe("bracketProgressionLabel", () => { + it("returns the short code for a single stage", () => { + expect(bracketProgressionLabel([{ type: "single_elimination" }])).toBe( + "SE", + ); + }); + + it("joins stages with an arrow", () => { + expect( + bracketProgressionLabel([ + { type: "round_robin" }, + { type: "single_elimination" }, + ]), + ).toBe("RR → SE"); + }); + + it("collapses consecutive duplicate stages", () => { + expect( + bracketProgressionLabel([ + { type: "single_elimination" }, + { type: "single_elimination" }, + { type: "double_elimination" }, + ]), + ).toBe("SE → DE"); + }); + + it("returns empty string for empty progression", () => { + expect(bracketProgressionLabel([])).toBe(""); + }); +}); diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 8868bf382..24af8bac0 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -422,6 +422,85 @@ export function getBracketProgressionLabel( return prefix; } +const LEADING_SEPARATOR_REGEX = /^[\s_-]+/; + +/** + * Splits a tournament name into its series name and a trailing "subtext" + * (e.g. an edition number like `"54"` or a date like `"May 2026"`) based on the + * names of the organization's tournament series. + * + * The longest series name that the tournament name starts with (case-insensitive) + * is treated as the base name and whatever follows it becomes the subtext. If the + * tournament name does not start with any of the series names, the whole name is + * returned with no subtext. + * + * @example + * // series: [{ name: "In The Zone" }] + * splitTournamentName("In The Zone 54", series) // { name: "In The Zone", subtext: "54" } + * splitTournamentName("In The Zone Winter", series) // { name: "In The Zone", subtext: "Winter" } + * splitTournamentName("Picnic Weekly", series) // { name: "Picnic Weekly" } + */ +export function splitTournamentName( + tournamentName: string, + series: Array<{ name: string }>, +): { name: string; subtext?: string } { + const trimmedName = tournamentName.trim(); + const nameLower = trimmedName.toLowerCase(); + + const matchingSeries = R.firstBy( + series.filter((s) => nameLower.startsWith(s.name.toLowerCase())), + [(s) => s.name.length, "desc"], + ); + + if (!matchingSeries) return { name: trimmedName }; + + const subtext = trimmedName + .slice(matchingSeries.name.length) + .replace(LEADING_SEPARATOR_REGEX, "") + .trim(); + + if (!subtext) return { name: matchingSeries.name }; + + return { name: matchingSeries.name, subtext }; +} + +const STAGE_TYPE_TO_SHORT_CODE: Record< + Tables["TournamentStage"]["type"], + string +> = { + single_elimination: "SE", + double_elimination: "DE", + round_robin: "RR", + swiss: "SW", +}; + +/** + * Builds a compact arrow-separated label describing the bracket progression of a tournament, + * derived from `settings.bracketProgression`. + * + * Each stage type is rendered as a short code (`RR`, `SE`, `DE`, `SW`) and consecutive duplicates + * are collapsed so e.g. two single-elimination stages still render as a single `SE`. + * + * @example + * // [{type: "round_robin"}, {type: "single_elimination"}] + * bracketProgressionLabel(progression) // "RR → SE" + */ +export function bracketProgressionLabel( + progression: Pick[], +): string { + if (progression.length === 0) return ""; + + const codes: string[] = []; + for (const bracket of progression) { + const code = STAGE_TYPE_TO_SHORT_CODE[bracket.type]; + if (codes.at(-1) !== code) { + codes.push(code); + } + } + + return codes.join(" → "); +} + /** * Returns a new `CastedMatchesInfo` with the cast assignment applied. Tracks history of streamed set per channel. * Deduplicates history by `matchId` so that correcting a wrong channel replaces the previous entry. diff --git a/app/features/tournament/tournament.module.css b/app/features/tournament/tournament.module.css index fa44934c3..a45bc2bad 100644 --- a/app/features/tournament/tournament.module.css +++ b/app/features/tournament/tournament.module.css @@ -524,7 +524,7 @@ .standingsDivider { width: 5px; - background-color: var(--color-bg-high); + background-color: var(--color-border-high); border-radius: var(--radius-box); } diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts index 45d1ea717..c1ccae633 100644 --- a/app/features/user-page/user-page-schemas.ts +++ b/app/features/user-page/user-page-schemas.ts @@ -82,7 +82,7 @@ export const userEditProfileBaseSchema = z.object({ }, }), inGameName: textFieldOptional({ - label: "labels.profileInGameName", + label: "labels.inGameName", bottomText: "bottomTexts.profileInGameName", maxLength: USER.IN_GAME_NAME_TEXT_MAX_LENGTH + diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 5f3305995..9972dc9b8 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -16,6 +16,7 @@ import { import { SelectFormField } from "./fields/SelectFormField"; import { StageSelectFormField } from "./fields/StageSelectFormField"; import { SwitchFormField } from "./fields/SwitchFormField"; +import { TeamSearchFormField } from "./fields/TeamSearchFormField"; import { TextareaFormField } from "./fields/TextareaFormField"; import { TimeRangeFormField } from "./fields/TimeRangeFormField"; import { TournamentSearchFormField } from "./fields/TournamentSearchFormField"; @@ -34,8 +35,11 @@ import type { FormFieldItemsWithImage, FormField as FormFieldType, SelectOption, + TeamSearchFieldOptions, + TournamentSearchFieldOptions, } from "./types"; import { + fieldsetDefaults, getNestedSchema, getNestedValue, setNestedValue, @@ -125,7 +129,12 @@ export function FormField({ const handleChange = React.useCallback( (newValue: unknown) => { context?.setValue(name, newValue); - if (hasSubmitted && context) { + context?.clearServerError(name); + if ( + hasSubmitted && + context && + !isArrayAppend(context.values, name, newValue) + ) { const updatedValues = isNestedPath ? setNestedValue(context.values, name, newValue) : { ...context.values, [name]: newValue }; @@ -334,7 +343,7 @@ export function FormField({ const hasCustomRender = typeof children === "function"; const itemInitialValue = isObjectArray && innerFieldMeta - ? computeFieldsetInitialValue(innerFieldMeta) + ? fieldsetDefaults(innerFieldMeta) : innerFieldMeta?.initialValue; return ( @@ -403,12 +412,29 @@ export function FormField({ } if (formField.type === "tournament-search") { + const tournamentOptions = options as + | TournamentSearchFieldOptions + | undefined; return ( void} + pastOnly={tournamentOptions?.pastOnly} + /> + ); + } + + if (formField.type === "team-search") { + const teamOptions = options as TeamSearchFieldOptions | undefined; + return ( + void} + onTeamSelected={teamOptions?.onTeamSelected} + initialTeam={teamOptions?.initialTeam} /> ); } @@ -456,22 +482,13 @@ export function FormField({ ); } -function computeFieldsetInitialValue( - fieldsetMeta: FormFieldType, -): Record { - if (fieldsetMeta.type !== "fieldset") return {}; - - const shape = fieldsetMeta.fields.shape as Record; - const result: Record = {}; - - for (const [key, fieldSchema] of Object.entries(shape)) { - const fieldMeta = formRegistry.get(fieldSchema) as - | FormFieldType - | undefined; - if (fieldMeta) { - result[key] = fieldMeta.initialValue; - } - } - - return result; +function isArrayAppend( + values: Record, + name: string, + newValue: unknown, +): boolean { + if (!Array.isArray(newValue)) return false; + const isNestedPath = name.includes(".") || name.includes("["); + const prevValue = isNestedPath ? getNestedValue(values, name) : values[name]; + return Array.isArray(prevValue) && newValue.length > prevValue.length; } diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index 4dd60fad7..aa45ebd91 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -887,6 +887,27 @@ describe("SendouForm", () => { .toBeVisible(); }); + test("renders one starter item for an empty array", async () => { + const schema = z.object({ + urls: array({ + label: "labels.urls", + min: 0, + max: 5, + field: textFieldRequired({ maxLength: 100 }), + }), + }); + + const screen = await renderForm(schema); + + const inputs = screen.container.querySelectorAll('input[type="text"]'); + expect(inputs.length).toBe(1); + + const removeButtons = screen.container.querySelectorAll( + 'button[aria-label="Remove item"]', + ); + expect(removeButtons.length).toBe(0); + }); + test("clicking add creates new item", async () => { const schema = z.object({ urls: array({ @@ -899,10 +920,17 @@ describe("SendouForm", () => { const screen = await renderForm(schema); + // Adding from the single empty starter row materializes it and appends a + // new one, so one click goes from 1 visible row to 2. await screen.getByRole("button", { name: "Add" }).click(); + expect( + screen.container.querySelectorAll('input[type="text"]').length, + ).toBe(2); - const inputs = screen.container.querySelectorAll('input[type="text"]'); - expect(inputs.length).toBe(1); + await screen.getByRole("button", { name: "Add" }).click(); + expect( + screen.container.querySelectorAll('input[type="text"]').length, + ).toBe(3); }); test("renders remove button for each item when above minimum", async () => { @@ -993,6 +1021,33 @@ describe("SendouForm", () => { await expect.element(screen.getByLabelText("Name")).toHaveValue("Alice"); }); + test("renders one starter fieldset for an empty array", async () => { + const schema = z.object({ + members: array({ + label: "labels.members", + min: 0, + max: 10, + field: fieldset({ + fields: z.object({ + name: textFieldRequired({ label: "labels.name", maxLength: 100 }), + }), + }), + }), + }); + + const screen = await renderForm(schema); + + await expect.element(screen.getByText("#1")).toBeVisible(); + + // The remove button is rendered but hidden (so the header keeps a stable + // height) since a single starter row can't be removed. + const removeButtons = screen.container.querySelectorAll( + 'button[aria-label="Remove item"]', + ); + expect(removeButtons.length).toBe(1); + expect(removeButtons[0].classList.contains("invisible")).toBe(true); + }); + test("add button creates new fieldset item", async () => { const schema = z.object({ members: array({ @@ -1009,9 +1064,10 @@ describe("SendouForm", () => { const screen = await renderForm(schema); + await screen.getByRole("button", { name: "Add" }).click(); await screen.getByRole("button", { name: "Add" }).click(); - await expect.element(screen.getByText("#1")).toBeVisible(); + await expect.element(screen.getByText("#2")).toBeVisible(); }); test("remove button removes fieldset item", async () => { @@ -1044,6 +1100,57 @@ describe("SendouForm", () => { expect((inputs[0] as HTMLInputElement).value).toBe("Bob"); }); + test("removing an added fieldset row returns to a single non-removable row", async () => { + // Mirrors the staff form: a select field gives the row a non-empty default + // (role), so a freshly added row isn't "blank" yet is still pristine. + const schema = z.object({ + staff: array({ + label: "labels.members", + min: 0, + max: 10, + field: fieldset({ + fields: z.object({ + name: textFieldRequired({ label: "labels.name", maxLength: 100 }), + role: select({ + label: "labels.staffRole", + items: [ + { value: "ORGANIZER", label: "options.staffRole.ORGANIZER" }, + { value: "STREAMER", label: "options.staffRole.STREAMER" }, + ], + }), + }), + }), + }), + }); + + const screen = await renderForm(schema); + + const removeButtonEls = () => + screen.container.querySelectorAll('button[aria-label="Remove item"]'); + + // Single starter row: remove button present but hidden. + expect(removeButtonEls().length).toBe(1); + expect(removeButtonEls()[0].classList.contains("invisible")).toBe(true); + + await screen.getByRole("button", { name: "Add" }).click(); + + // Two rows now, both with visible remove buttons. + await expect.element(screen.getByText("#2")).toBeVisible(); + expect(removeButtonEls().length).toBe(2); + for (const button of removeButtonEls()) { + expect(button.classList.contains("invisible")).toBe(false); + } + + // Removing the second row collapses back to the single starter row with a + // hidden remove button - not a lingering blank row that still shows one. + await userEvent.click(removeButtonEls()[1]); + + await expect.element(screen.getByText("#1")).toBeVisible(); + expect(screen.container.querySelectorAll("fieldset").length).toBe(1); + expect(removeButtonEls().length).toBe(1); + expect(removeButtonEls()[0].classList.contains("invisible")).toBe(true); + }); + test("typing in nested fieldset field updates value", async () => { const schema = z.object({ members: array({ @@ -1068,6 +1175,56 @@ describe("SendouForm", () => { await expect.element(input).toHaveValue("New Name"); }); + test("editing a starter row commits its select default on submit", async () => { + // Regression: editing one field of the empty-array starter row must seed the + // item's other fieldset defaults (e.g. a required select's first option), + // rather than leaving them only displayed as a fallback and failing + // validation on submit. + const onApply = vi.fn(); + const schema = z.object({ + staff: array({ + label: "labels.members", + min: 0, + max: 10, + field: fieldset({ + fields: z.object({ + name: textFieldRequired({ label: "labels.name", maxLength: 100 }), + role: select({ + label: "labels.staffRole", + items: [ + { value: "ORGANIZER", label: "options.staffRole.ORGANIZER" }, + { value: "STREAMER", label: "options.staffRole.STREAMER" }, + ], + }), + }), + }), + }), + }); + + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + {({ names }) => } + + ), + }, + ], + { initialEntries: ["/"] }, + ); + + const screen = await render(); + + await userEvent.type(screen.getByLabelText("Name").element(), "Alice"); + await screen.getByRole("button", { name: "Submit" }).click(); + + expect(onApply).toHaveBeenCalledWith({ + staff: [{ name: "Alice", role: "ORGANIZER" }], + }); + }); + test("shows error on specific nested field within array item", async () => { const schema = z.object({ series: array({ diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index dc717651e..4fc82fbb1 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -11,8 +11,10 @@ import { formRegistry } from "./fields"; import styles from "./SendouForm.module.css"; import type { FormField, TypedFormFieldComponent } from "./types"; import { + buildFieldPath, errorMessageId, getNestedValue, + seedArrayItemDefaults, setNestedValue, validateField, } from "./utils"; @@ -31,6 +33,7 @@ export interface FormContextValue { clientErrors: Partial>; hasSubmitted: boolean; setClientError: (name: string, error: string | undefined) => void; + clearServerError: (name: string) => void; onFieldChange?: (name: string, newValue: unknown) => void; values: Record; setValue: (name: string, value: unknown) => void; @@ -72,6 +75,12 @@ type BaseFormProps = { fullWidth?: boolean; onApply?: (values: z.infer>) => void; secondarySubmit?: React.ReactNode; + /** + * Called once after a server submission completes successfully (the action + * returned without field errors). Useful for collapsing an inline edit form + * back to a read-only view. + */ + onSuccess?: () => void; }; type SendouFormProps = BaseFormProps & @@ -99,6 +108,7 @@ export function SendouForm({ fullWidth, onApply, secondarySubmit, + onSuccess, }: SendouFormProps) { const { t } = useTranslation(["forms"]); const fetcher = useFetcher<{ fieldErrors?: Record }>(); @@ -163,6 +173,18 @@ export function SendouForm({ firstErrorElement?.scrollIntoView({ behavior: "smooth", block: "center" }); }, [fetcher.data, t]); + const previousFetcherStateRef = React.useRef(fetcher.state); + React.useEffect(() => { + if ( + previousFetcherStateRef.current !== "idle" && + fetcher.state === "idle" && + !fetcher.data?.fieldErrors + ) { + onSuccess?.(); + } + previousFetcherStateRef.current = fetcher.state; + }, [fetcher.state, fetcher.data, onSuccess]); + const serverErrors = visibleServerErrors as Partial< Record>, string> >; @@ -178,9 +200,36 @@ export function SendouForm({ }); }; + // Server errors are keyed by positional path (e.g. `members[2].userId`). When + // the user edits a field, the server's verdict for that field — and for any + // nested descendants when an array/object changes — is stale, so drop it. + // Without this, removing an array item and re-adding one at the same index + // would resurrect the previous item's server error. + const clearServerError = (name: string) => { + setVisibleServerErrors((prev) => { + const isStale = (key: string) => + key === name || + key.startsWith(`${name}.`) || + key.startsWith(`${name}[`); + if (!Object.keys(prev).some(isStale)) return prev; + + const next: Partial> = {}; + for (const [key, value] of Object.entries(prev)) { + if (!isStale(key)) next[key] = value; + } + return next; + }); + }; + const setValue = (name: string, newValue: unknown) => { if (name.includes(".") || name.includes("[")) { - setValues((prev) => setNestedValue(prev, name, newValue)); + setValues((prev) => + setNestedValue( + seedArrayItemDefaults(schema, prev, name), + name, + newValue, + ), + ); } else { setValues((prev) => ({ ...prev, [name]: newValue })); } @@ -335,6 +384,7 @@ export function SendouForm({ clientErrors, hasSubmitted, setClientError, + clearServerError, onFieldChange, revalidateAll, values, @@ -410,6 +460,7 @@ export function SendouForm({ method={method} action={action} className={resolvedClassName} + noValidate onSubmit={handleSubmit} > {formContent} @@ -419,19 +470,6 @@ export function SendouForm({ ); } -function buildFieldPath(path: PropertyKey[]): string | null { - if (path.length === 0) return null; - - return path - .map((segment, index) => { - if (typeof segment === "number") return `[${segment}]`; - if (typeof segment === "symbol") return null; - return index === 0 ? segment : `.${segment}`; - }) - .filter((part) => part !== null) - .join(""); -} - function computeInitialErrors( schema: z.ZodObject, values: Record, diff --git a/app/form/fields.ts b/app/form/fields.ts index 6096d5e9c..dadce2ce8 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -26,6 +26,8 @@ import type { FormFieldSelect, FormsTranslationKey, SelectOption, + TeamSearchFieldOptions, + TournamentSearchFieldOptions, } from "./types"; export const formRegistry = z.registry(); @@ -764,7 +766,27 @@ export function tournamentSearchOptional( type: "tournament-search", initialValue: null, required: false, - }); + }) as unknown as z.ZodType & + FieldWithOptions; +} + +export function teamSearchOptional( + args: WithTypedTranslationKeys< + Omit< + Extract, + "type" | "initialValue" | "required" + > + >, +) { + return z.preprocess(falsyToNull, id.nullable()).register(formRegistry, { + ...args, + label: prefixKey(args.label), + bottomText: prefixKey(args.bottomText), + type: "team-search", + initialValue: null, + required: false, + }) as unknown as z.ZodType & + FieldWithOptions; } export function badges( diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index fe67c7749..5dfa398af 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -1,6 +1,7 @@ import { Plus, Trash } from "lucide-react"; import type * as React from "react"; import { useTranslation } from "react-i18next"; +import { isDeepEqual, omit } from "remeda"; import { SendouButton } from "~/components/elements/Button"; import { FormMessage } from "~/components/FormMessage"; import type { FormFieldProps } from "../types"; @@ -36,26 +37,66 @@ export function ArrayFormField({ useTranslatedTexts({ label, bottomText, error }); const count = value.length; + // Always render at least one item so an empty array still shows an input + // the user can fill, rather than only an "Add" button. The underlying value + // stays empty until edited, so submitting an untouched field sends nothing. + const minVisible = Math.max(min, 1); + const visibleCount = Math.max(count, minVisible); - const handleAdd = () => { + const makeNewItem = () => { const baseValue = itemInitialValue !== undefined ? itemInitialValue : isObjectArray ? {} : undefined; - const newItemValue = - typeof baseValue === "object" && baseValue !== null - ? { - ...(baseValue as Record), - _key: crypto.randomUUID(), - } - : baseValue; - onChange([...value, newItemValue]); + return typeof baseValue === "object" && baseValue !== null + ? { + ...(baseValue as Record), + _key: crypto.randomUUID(), + } + : baseValue; }; + const handleAdd = () => { + // While the array is empty we still render one placeholder row that isn't + // part of `value` yet. Pad `value` up to the number of visible rows first so + // the added item appears below them instead of only backing the placeholder. + const padded = [...value]; + while (padded.length < visibleCount) { + padded.push(makeNewItem()); + } + onChange([...padded, makeNewItem()]); + }; + + // An item the user hasn't touched still equals the freshly added template, so + // it's indistinguishable from the placeholder shown for an empty array. + const isPristineItem = (item: unknown) => { + const template = itemInitialValue; + if (typeof template === "object" && template !== null) { + if (typeof item !== "object" || item === null) return true; + return isDeepEqual( + omit(item as Record, ["_key"]), + template, + ); + } + return template === undefined + ? item === null || item === undefined || item === "" + : isDeepEqual(item, template); + }; + + // A single pristine row is indistinguishable from the empty-array placeholder, + // so it shouldn't offer a remove button (you can't go below one visible row + // anyway). A lone edited row stays removable so the only item can be cleared. + const canRemoveAt = (index: number) => + count > min && (count > minVisible || !isPristineItem(value[index])); + const handleRemoveAt = (index: number) => { - onChange(value.filter((_, i) => i !== index)); + const next = value.filter((_, i) => i !== index); + // Removing down to a single pristine row would leave a stray entry that + // looks untouched but still fails validation on submit; collapse it back to + // an empty array so it matches the pristine state. + onChange(next.length === 1 && isPristineItem(next[0]) ? [] : next); }; const itemKey = (idx: number) => { @@ -68,12 +109,12 @@ export function ArrayFormField({ {translatedLabel ? (
{translatedLabel}
) : null} - {Array.from({ length: count }).map((_, idx) => + {Array.from({ length: visibleCount }).map((_, idx) => isObjectArray ? ( min} + canRemove={canRemoveAt(idx)} onRemove={() => handleRemoveAt(idx)} sortable={sortable} > @@ -87,7 +128,7 @@ export function ArrayFormField({
{renderItem(idx, `${name}[${idx}]`)}
- {count > min ? ( + {canRemoveAt(idx) ? ( } aria-label="Remove item" @@ -107,6 +148,7 @@ export function ArrayFormField({ ) : null} } onPress={handleAdd} isDisabled={count >= max} @@ -136,16 +178,16 @@ function ArrayItemFieldset({
{sortable ? : null} #{index + 1} - {canRemove ? ( - } - aria-label="Remove item" - size="small" - variant="minimal-destructive" - onPress={onRemove} - /> - ) : null} + } + aria-label="Remove item" + size="small" + variant="minimal-destructive" + onPress={onRemove} + isDisabled={!canRemove} + />
{children}
diff --git a/app/form/fields/TeamSearchFormField.tsx b/app/form/fields/TeamSearchFormField.tsx new file mode 100644 index 000000000..7c9dd0b87 --- /dev/null +++ b/app/form/fields/TeamSearchFormField.tsx @@ -0,0 +1,43 @@ +import { TeamSearch } from "~/components/elements/TeamSearch"; +import type { FormFieldProps, TeamSearchFieldOptions } from "../types"; +import { FormFieldMessages, useTranslatedTexts } from "./FormFieldWrapper"; +import styles from "./UserSearchFormField.module.css"; + +type TeamSearchFormFieldProps = FormFieldProps<"team-search"> & + TeamSearchFieldOptions & { + onChange: (value: number | null) => void; + }; + +export function TeamSearchFormField({ + name, + label, + bottomText, + error, + required, + onChange, + onBlur, + onTeamSelected, + initialTeam, +}: TeamSearchFormFieldProps) { + const { translatedLabel } = useTranslatedTexts({ + label, + }); + + return ( +
+
+ { + onChange(team?.id ?? null); + onTeamSelected?.(team); + }} + onBlur={() => onBlur?.()} + label={translatedLabel} + isRequired={required} + /> + +
+
+ ); +} diff --git a/app/form/fields/TournamentSearchFormField.tsx b/app/form/fields/TournamentSearchFormField.tsx index 7f5e2c11e..482013bf2 100644 --- a/app/form/fields/TournamentSearchFormField.tsx +++ b/app/form/fields/TournamentSearchFormField.tsx @@ -6,6 +6,7 @@ import styles from "./UserSearchFormField.module.css"; type TournamentSearchFormFieldProps = FormFieldProps<"tournament-search"> & { value: number | null; onChange: (value: number | null) => void; + pastOnly?: boolean; }; export function TournamentSearchFormField({ @@ -17,6 +18,7 @@ export function TournamentSearchFormField({ value, onChange, onBlur, + pastOnly, }: TournamentSearchFormFieldProps) { const { translatedLabel } = useTranslatedTexts({ label, @@ -27,6 +29,7 @@ export function TournamentSearchFormField({
onChange(tournament?.id ?? null)} onBlur={() => onBlur?.()} label={translatedLabel} diff --git a/app/form/parse.server.ts b/app/form/parse.server.ts index 2ada85e05..008cee3a6 100644 --- a/app/form/parse.server.ts +++ b/app/form/parse.server.ts @@ -4,11 +4,28 @@ import { imageFieldValueToImgId } from "~/features/img-upload/image-field.server import { formDataToObject } from "~/utils/remix.server"; import { formRegistry } from "./fields"; import type { ImageFieldValue } from "./image-field"; +import { buildFieldPath } from "./utils"; export type ParseResult = | { success: true; data: T } | { success: false; fieldErrors: Record }; +/** + * Maps a {@link z.ZodError} to field-level errors keyed by form field name + * (e.g. `members[0].userId`), keeping the first error per field. + */ +function fieldErrorsFromZodError(error: z.ZodError): Record { + const fieldErrors: Record = {}; + for (const issue of error.issues) { + const path = buildFieldPath(issue.path); + if (path && !fieldErrors[path]) { + fieldErrors[path] = issue.message; + } + } + + return fieldErrors; +} + /** * Parses request body against a Zod schema. * Handles both JSON (SendouForm) and form data (FormWithConfirm) based on Content-Type. @@ -32,15 +49,7 @@ export async function parseFormData({ return { success: true, data: result.data }; } - const fieldErrors: Record = {}; - for (const issue of result.error.issues) { - const path = issue.path.join("."); - if (path && !fieldErrors[path]) { - fieldErrors[path] = issue.message; - } - } - - return { success: false, fieldErrors }; + return { success: false, fieldErrors: fieldErrorsFromZodError(result.error) }; } /** Image field values collapse to their stored id; everything else passes through. */ diff --git a/app/form/types.ts b/app/form/types.ts index 347899d3a..c9f112238 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { TeamSearchResult } from "~/components/elements/TeamSearch"; import type { ModeShort } from "~/modules/in-game-lists/types"; import type forms from "../../locales/en/forms.json"; import type { ImageFieldDimensions } from "./image-field"; @@ -147,6 +148,10 @@ interface FormFieldTournamentSearch extends FormFieldBase { required: boolean; } +interface FormFieldTeamSearch extends FormFieldBase { + required: boolean; +} + interface FormFieldBadges extends FormFieldBase { maxCount?: number; } @@ -193,6 +198,7 @@ export type FormField = | FormFieldFieldset<"fieldset", z.ZodRawShape> | FormFieldUserSearch<"user-search"> | FormFieldTournamentSearch<"tournament-search"> + | FormFieldTeamSearch<"team-search"> | FormFieldBadges<"badges"> | FormFieldStageSelect<"stage-select"> | FormFieldWeaponSelect<"weapon-select">; @@ -291,3 +297,26 @@ export type TypedFormFieldComponent = { ): React.ReactNode; (props: FlexibleFormFieldProps): React.ReactNode; }; + +/** + * Runtime config consumed only by the `team-search` field. Passed via the + * `options` prop (the same channel `badges`/`select-dynamic` use), so it stays + * scoped to this field type instead of polluting every `FormField`. + * + * `initialTeam` carries the selected team's display data (name, avatar) for the + * edit/prefill case — that metadata is not part of the stored form value (a + * plain team id), so it cannot come from `defaultValues`. + */ +export type TeamSearchFieldOptions = { + onTeamSelected?: (team: TeamSearchResult | null) => void; + initialTeam?: { id: number; name: string; avatarUrl?: string | null }; +}; + +/** + * Runtime config consumed only by the `tournament-search` field, passed via the + * `options` prop (the same channel `team-search` uses). + */ +export type TournamentSearchFieldOptions = { + /** Restrict results to tournaments that have already started (finished/past). */ + pastOnly?: boolean; +}; diff --git a/app/form/utils.ts b/app/form/utils.ts index ad6a2d2f9..7de3c3227 100644 --- a/app/form/utils.ts +++ b/app/form/utils.ts @@ -1,9 +1,28 @@ import type { z } from "zod"; +import { formRegistry } from "./fields"; +import type { FormField } from "./types"; function infoMessageId(fieldId: string) { return `${fieldId}-info`; } +/** + * Builds a form field name (e.g. `members[0].userId`) from a Zod issue path so + * that server- and client-side validation errors key fields identically. + */ +export function buildFieldPath(path: PropertyKey[]): string | null { + if (path.length === 0) return null; + + return path + .map((segment, index) => { + if (typeof segment === "number") return `[${segment}]`; + if (typeof segment === "symbol") return null; + return index === 0 ? segment : `.${segment}`; + }) + .filter((part) => part !== null) + .join(""); +} + export function getNestedValue( obj: Record, path: string, @@ -75,6 +94,64 @@ export function setNestedValue( }; } +// Casting away the registry's deep generic signature avoids "Type instantiation +// is excessively deep" errors when looking up field metadata by schema. +const typedRegistry = formRegistry as { + get(schema: z.ZodType): FormField | undefined; +}; + +/** + * The default value object for a `fieldset` field, built from each sub-field's + * own `initialValue` (e.g. a `select`'s first option). Returns `{}` for + * non-fieldset fields. + */ +export function fieldsetDefaults( + fieldsetMeta: FormField, +): Record { + if (fieldsetMeta.type !== "fieldset") return {}; + + const shape = fieldsetMeta.fields.shape as Record; + const result: Record = {}; + for (const [key, fieldSchema] of Object.entries(shape)) { + const fieldMeta = typedRegistry.get(fieldSchema); + if (fieldMeta) result[key] = fieldMeta.initialValue; + } + return result; +} + +/** + * When a leaf field inside an array-of-fieldset item is edited (e.g. + * `staff[0].userId`), the enclosing item is created on demand. Without this the + * item would only contain the touched field, dropping defaults that were merely + * shown as a fallback (e.g. a required `select`'s first option) and failing + * validation on submit. This seeds the item with its fieldset defaults, keeping + * any values it already has. Untouched items are never created, so this doesn't + * affect submitting a pristine form. + */ +export function seedArrayItemDefaults( + schema: z.ZodObject, + values: Record, + name: string, +): Record { + const lastBracket = name.lastIndexOf("]"); + // No enclosing array item (`-1`) or the leaf is the array element itself + // (path ends in `]`, i.e. a primitive array) — nothing to seed. + if (lastBracket === -1 || lastBracket === name.length - 1) return values; + + const itemPath = name.slice(0, lastBracket + 1); + const itemSchema = getNestedSchema(schema, itemPath); + if (!itemSchema) return values; + + const itemMeta = typedRegistry.get(itemSchema); + if (itemMeta?.type !== "fieldset") return values; + + const existing = getNestedValue(values, itemPath) as + | Record + | undefined; + const merged = { ...fieldsetDefaults(itemMeta), ...(existing ?? {}) }; + return setNestedValue(values, itemPath, merged); +} + export function getNestedSchema( schema: z.ZodObject, path: string, @@ -146,12 +223,23 @@ export function validateField( const result = fieldSchema.safeParse(value); if (result.success) return undefined; - const issue = result.error.issues[0]; + // `array`/`fieldset` fields render each child as its own FormField with its + // own error slot, so a nested issue (e.g. an empty member inside a `members` + // array) belongs to that child — attributing it to the parent would surface + // the wrong message at the wrong field. Other composite fields (e.g. a custom + // tuple) render as a single control, so their nested issues belong to them. + const fieldMeta = typedRegistry.get(fieldSchema); + const childrenRenderOwnErrors = + fieldMeta?.type === "array" || fieldMeta?.type === "fieldset"; + const issue = childrenRenderOwnErrors + ? result.error.issues.find((i) => i.path.length === 0) + : result.error.issues[0]; if (!issue) return undefined; + const valueIsEmpty = value === null || value === undefined || value === ""; if ( - issue.code === "invalid_type" && - (value === null || value === undefined || value === "") + valueIsEmpty && + (issue.code === "invalid_type" || issue.code === "too_small") ) { return "forms:errors.required"; } diff --git a/app/modules/csv.test.ts b/app/modules/csv.test.ts new file mode 100644 index 000000000..86eef9030 --- /dev/null +++ b/app/modules/csv.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import * as CSV from "./csv"; + +describe("CSV.serialize", () => { + it("joins cells with commas and rows with CRLF", () => { + expect( + CSV.serialize([ + ["a", "b"], + ["c", "d"], + ]), + ).toBe("a,b\r\nc,d"); + }); + + it("quotes cells containing the delimiter", () => { + expect(CSV.serialize([["a,b", "c"]])).toBe('"a,b",c'); + }); + + it("escapes quotes by doubling them", () => { + expect(CSV.serialize([['say "hi"']])).toBe('"say ""hi"""'); + }); + + it("quotes cells containing line breaks", () => { + expect(CSV.serialize([["line1\nline2"]])).toBe('"line1\nline2"'); + expect(CSV.serialize([["line1\rline2"]])).toBe('"line1\rline2"'); + }); + + it("leaves plain cells unquoted", () => { + expect(CSV.serialize([["plain", "text"]])).toBe("plain,text"); + }); + + it("handles empty input", () => { + expect(CSV.serialize([])).toBe(""); + }); + + it("neutralizes formula injection by prefixing a quote", () => { + expect(CSV.serialize([["=1+1"]])).toBe("'=1+1"); + expect(CSV.serialize([["+1"]])).toBe("'+1"); + expect(CSV.serialize([["@SUM(A1)"]])).toBe("'@SUM(A1)"); + expect(CSV.serialize([["=HYPERLINK(1,2)"]])).toBe('"\'=HYPERLINK(1,2)"'); + }); + + it("does not treat negative numbers as formulas", () => { + expect(CSV.serialize([["-5"]])).toBe("-5"); + expect(CSV.serialize([["-5.5"]])).toBe("-5.5"); + }); + + it("guards a leading minus that is not numeric", () => { + expect(CSV.serialize([["-cmd"]])).toBe("'-cmd"); + }); +}); diff --git a/app/modules/csv.ts b/app/modules/csv.ts new file mode 100644 index 000000000..bddea18c0 --- /dev/null +++ b/app/modules/csv.ts @@ -0,0 +1,69 @@ +const DELIMITER = ","; +const ROW_SEPARATOR = "\r\n"; +const QUOTE = '"'; +const CHARACTERS_REQUIRING_QUOTING = [DELIMITER, QUOTE, "\n", "\r"]; + +const FORMULA_PREFIX = "'"; +const FORMULA_TRIGGERS = ["=", "+", "@", "\t", "\r"]; + +/** + * Byte order mark to prepend when writing the CSV to a file, so that Excel + * reads the bytes as UTF-8 and renders non-ASCII characters (e.g. Japanese or + * accented names) correctly. + */ +export const BOM = "\uFEFF"; + +/** + * Serializes a two-dimensional array of cell values into an RFC 4180 compliant + * CSV string. Cells are quoted only when they contain a delimiter, quote or + * line break, and any quotes inside a cell are escaped by doubling them. + * + * Cells whose value could be interpreted as a formula by spreadsheet software + * (a "CSV injection") are prefixed with a single quote, which neutralizes the + * formula while keeping the value readable. This matters because the input may + * be user-controlled (team names, usernames, ...). + * + * @example + * ```typescript + * serialize([ + * ["name", "note"], + * ["Sendou", 'say "hi"'], + * ["Test", "a,b"], + * ]); + * // name,note\r\nSendou,"say ""hi"""\r\nTest,"a,b" + * ``` + */ +export function serialize(rows: ReadonlyArray>): string { + return rows.map(serializeRow).join(ROW_SEPARATOR); +} + +function serializeRow(row: ReadonlyArray): string { + return row.map(serializeCell).join(DELIMITER); +} + +function serializeCell(value: string): string { + const safeValue = isFormulaInjectionRisk(value) + ? `${FORMULA_PREFIX}${value}` + : value; + + const needsQuoting = CHARACTERS_REQUIRING_QUOTING.some((character) => + safeValue.includes(character), + ); + if (!needsQuoting) return safeValue; + + return `${QUOTE}${safeValue.replaceAll(QUOTE, `${QUOTE}${QUOTE}`)}${QUOTE}`; +} + +function isFormulaInjectionRisk(value: string): boolean { + const firstCharacter = value[0]; + if (!firstCharacter) return false; + if (FORMULA_TRIGGERS.includes(firstCharacter)) return true; + // "-" can legitimately begin a negative number, so only guard it when the + // value isn't numeric and could therefore be read as a formula + if (firstCharacter === "-") return !isNumeric(value); + return false; +} + +function isNumeric(value: string): boolean { + return value.trim() !== "" && Number.isFinite(Number(value)); +} diff --git a/app/root.tsx b/app/root.tsx index 51228931f..f989b1c85 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -262,7 +262,7 @@ function useTriggerToasts() { ); } - navigate({ search: "" }, { replace: true }); + navigate({ search: "" }, { replace: true, defaultShouldRevalidate: false }); }, [error, success, navigate]); } diff --git a/app/routes.ts b/app/routes.ts index 962d1deab..820965d09 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -119,12 +119,36 @@ export default [ route("/to/search", "features/tournament/routes/to.search.ts"), route("/to/:id", "features/tournament/routes/to.$id.tsx", [ index("features/tournament/routes/to.$id.index.ts"), + route("info", "features/tournament/routes/to.$id.info.tsx"), route("register", "features/tournament/routes/to.$id.register.tsx"), + route("rules", "features/tournament/routes/to.$id.rules.tsx"), route("teams", "features/tournament/routes/to.$id.teams.tsx"), route("teams/:tid", "features/tournament/routes/to.$id.teams.$tid.tsx"), route("join", "features/tournament/routes/to.$id.join.tsx"), - route("admin", "features/tournament/routes/to.$id.admin.tsx"), - route("seeds", "features/tournament/routes/to.$id.seeds.tsx"), + route("admin", "features/tournament-admin/routes/to.$id.admin.tsx", [ + layout("features/tournament-admin/routes/to.$id.admin.index.tsx", [ + index("features/tournament-admin/routes/to.$id.admin._index.tsx"), + route( + "registration/:tid?", + "features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx", + ), + ]), + route( + "import-teams", + "features/tournament-admin/routes/to.$id.admin.import-teams.ts", + ), + route("seeds", "features/tournament-admin/routes/to.$id.admin.seeds.tsx"), + route("staff", "features/tournament-admin/routes/to.$id.admin.staff.tsx"), + route( + "stream", + "features/tournament-admin/routes/to.$id.admin.stream.tsx", + ), + route( + "brackets", + "features/tournament-admin/routes/to.$id.admin.brackets.tsx", + ), + route("audit", "features/tournament-admin/routes/to.$id.admin.audit.tsx"), + ]), route("results", "features/tournament/routes/to.$id.results.tsx"), route("streams", "features/tournament/routes/to.$id.streams.tsx"), diff --git a/app/routines/deleteOldTournamentAuditLogs.ts b/app/routines/deleteOldTournamentAuditLogs.ts new file mode 100644 index 000000000..b8f78545e --- /dev/null +++ b/app/routines/deleteOldTournamentAuditLogs.ts @@ -0,0 +1,11 @@ +import * as TournamentAuditLogRepository from "../features/tournament/TournamentAuditLogRepository.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +export const DeleteOldTournamentAuditLogsRoutine = new Routine({ + name: "DeleteOldTournamentAuditLogs", + func: async () => { + const { numDeletedRows } = await TournamentAuditLogRepository.deleteOld(); + logger.info(`Deleted ${numDeletedRows} old tournament audit log entries`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index ecf768705..fb413fd51 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -3,6 +3,7 @@ import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes"; import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods"; import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications"; import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks"; +import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs"; import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags"; import { NotifyCheckInStartRoutine } from "./notifyCheckInStart"; import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting"; @@ -37,6 +38,7 @@ export const everyHourAt30 = [ export const daily = [ DeleteObsoleteMatchVodsRoutine, DeleteOldNotificationsRoutine, + DeleteOldTournamentAuditLogsRoutine, CloseExpiredCommissionsRoutine, DeleteOrphanArtTagsRoutine, OptimizeDatabaseRoutine, diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts index 4c4c786a6..c2af564cd 100644 --- a/app/utils/remix.server.ts +++ b/app/utils/remix.server.ts @@ -30,6 +30,11 @@ export function unauthorizedIfFalsy(value: T | null | undefined): T { return value; } +/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */ +export function forbidden() { + throw new Response(null, { status: 403 }); +} + export function badRequestIfFalsy(value: T | null | undefined): T { if (!value) { throw new Response(null, { status: 400 }); @@ -313,6 +318,15 @@ export type SendouRouteHandle = { /** The name of a navItem that is active on this route. See nav-items.ts */ navItemName?: (typeof navItems)[number]["name"]; + + /** + * When `true`, the shared `
` rendered by a parent layout (e.g. the + * tournament layout) fills the whole content area instead of the page + * max-width, while the page content stays centered at the normal width. + * Lets a descendant (e.g. the bracket) break out and grow wider than the + * page when it needs to. + */ + mainBreakout?: boolean; }; /** Caches the loader response with "private" Cache-Control meaning that CDN won't cache the response. diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 3ebcfed3f..dee133068 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -322,10 +322,28 @@ export const tournamentTeamPage = ({ tournamentId: number; tournamentTeamId: number; }) => `/to/${tournamentId}/teams/${tournamentTeamId}`; +export const tournamentInfoPage = (tournamentId: number) => + `/to/${tournamentId}/info`; export const tournamentRegisterPage = (tournamentId: number) => `/to/${tournamentId}/register`; +export const tournamentRulesPage = (tournamentId: number) => + `/to/${tournamentId}/rules`; export const tournamentAdminPage = (tournamentId: number) => `/to/${tournamentId}/admin`; +export const tournamentAdminRegistrationPage = (tournamentId: number) => + `${tournamentAdminPage(tournamentId)}/registration`; +export const tournamentAdminRegistrationEditPage = ( + tournamentId: number, + tournamentTeamId: number, +) => `${tournamentAdminRegistrationPage(tournamentId)}/${tournamentTeamId}`; +export const tournamentAdminImportTeamsPage = ({ + tournamentId, + fromTournamentId, +}: { + tournamentId: number; + fromTournamentId: number; +}) => + `${tournamentAdminPage(tournamentId)}/import-teams?fromTournamentId=${fromTournamentId}`; export const tournamentBracketsPage = ({ tournamentId, bracketIdx, diff --git a/app/utils/zod.ts b/app/utils/zod.ts index bfdb72849..0603e84cd 100644 --- a/app/utils/zod.ts +++ b/app/utils/zod.ts @@ -19,7 +19,6 @@ export const id = z.coerce.number({ message: "Required" }).int().positive(); export const idObject = z.object({ id, }); -export const optionalId = z.coerce.number().int().positive().optional(); export const inviteCode = z.string().length(SHORT_NANOID_LENGTH); export const inviteCodeObject = z.object({ diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 40e455134..182ca557d 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/helpers/playwright.ts b/e2e/helpers/playwright.ts index b2b1520dd..d5c813608 100644 --- a/e2e/helpers/playwright.ts +++ b/e2e/helpers/playwright.ts @@ -99,6 +99,21 @@ export async function selectUser({ await page.keyboard.press("Enter"); } +export async function selectTournament({ + page, + query, +}: { + page: Page; + query: string; +}) { + const item = page.getByTestId("tournament-search-item"); + + await page.getByRole("button", { name: /Tournament search/i }).click(); + await page.getByTestId("tournament-search-input").fill(query); + await expect(item.first()).toBeVisible(); + await item.first().click(); +} + /** page.goto that waits for the page to be hydrated before proceeding */ export async function navigate({ page, url }: { page: Page; url: string }) { // Rewrite absolute URLs with localhost to use the worker's baseURL @@ -161,17 +176,17 @@ export async function submit(page: Page, testId?: string) { // Remix returns 202 from action endpoints when the action threw/returned a // redirect. The fetcher then drives a client-side navigation and, once - // that completes, fires a partial revalidation GET against the route data. - // If we return before that revalidation fires, a subsequent Link click can - // be aborted mid-flight by the queued revalidation (ERR_ABORTED on the new - // route's .data fetch), leaving the test on the old page. + // that completes, fires a GET against the new route's data. If we return + // before that GET fires, a subsequent Link click can be aborted mid-flight + // by the queued navigation (ERR_ABORTED on the new route's .data fetch), + // leaving the test on the old page. if (postRes.status() === 202) { await page.waitForResponse( - (res) => - res.request().method() === "GET" && - res.url().includes(".data") && - !/__(?:success|error)=/.test(res.url()), + (res) => res.request().method() === "GET" && res.url().includes(".data"), ); + // Toast flash params are stripped right after via a replace navigation + // (without revalidation); wait for it so it can't abort a later click. + await expect(page).not.toHaveURL(/__(?:success|error)=/); } } @@ -191,6 +206,18 @@ export function modalClickConfirmButton(page: Page) { return submit(page, "confirm-button"); } +/** + * Clicks a tournament nav tab by its testId, opening the overflow ("More") menu + * first when the tab has collapsed into it on the current viewport. + */ +export async function clickNavTab(page: Page, testId: string) { + const visibleTab = page.locator(`[data-testid="${testId}"]:visible`); + if ((await visibleTab.count()) === 0) { + await page.getByRole("button", { name: "More" }).click(); + } + await visibleTab.click(); +} + export const startBracket = async (page: Page, tournamentId = 2) => { await seed(page); await impersonate(page); diff --git a/e2e/org.spec.ts b/e2e/org.spec.ts index 8bcd64630..0cf13780b 100644 --- a/e2e/org.spec.ts +++ b/e2e/org.spec.ts @@ -140,10 +140,10 @@ test.describe("Tournament Organization", () => { }); // Try to create a team - await page.getByRole("tab", { name: "Register" }).click(); + await page.getByTestId("register-cta").click(); // Fill in team details - await page.getByLabel("Team name").fill("Banned Team"); + await page.getByLabel("Pick-up name").fill("Banned Team"); await waitForPOSTResponse(page, () => page.getByTestId("save-team-button").click(), ); @@ -164,14 +164,14 @@ test.describe("Tournament Organization", () => { page, url: tournamentPage(1), }); - await page.getByRole("tab", { name: "Register" }).click(); + await page.getByTestId("register-cta").click(); // Try to create a team again await expect(page.getByText(/Teams \(\d+\)/)).toBeVisible(); const teamCountBefore = await page.getByText(/Teams \(\d+\)/).textContent(); - await page.getByLabel("Team name").fill("Unbanned Team"); + await page.getByLabel("Pick-up name").fill("Unbanned Team"); await page.getByTestId("save-team-button").click(); const countBefore = Number(teamCountBefore?.match(/\d+/)?.[0] ?? 0); diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 5538d5d5b..89c58eb60 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 5f9ea373d..3f03e4b77 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index e2f044f21..60c14efbf 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 81e975737..688349df1 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 3afd8a8da..1edc40e36 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index c98bd6c14..0622b8554 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index e9b3813c7..e81ecf56c 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 63ae204e4..f73745f4d 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 69dac62ed..e5aa1c596 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index c185ba70e..3a2aa434c 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index c3ee1f52b..e557955c1 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts index ba8f4eda1..3f2988fd8 100644 --- a/e2e/tournament-ab-divisions.spec.ts +++ b/e2e/tournament-ab-divisions.spec.ts @@ -22,7 +22,7 @@ test.describe("Tournament A/B divisions", () => { await navigate({ page, - url: `/to/${AB_RR_TOURNAMENT_ID}/seeds`, + url: `/to/${AB_RR_TOURNAMENT_ID}/admin/seeds`, }); await page.getByTestId("set-ab-divisions").click(); diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts new file mode 100644 index 000000000..dc12c8c07 --- /dev/null +++ b/e2e/tournament-admin.spec.ts @@ -0,0 +1,217 @@ +import { STAFF_TEST_ID } from "~/db/seed/constants"; +import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants"; +import { + tournamentAdminPage, + tournamentAdminRegistrationEditPage, + tournamentAdminRegistrationPage, +} from "~/utils/urls"; +import { + expect, + impersonate, + modalClickConfirmButton, + navigate, + seed, + selectTournament, + selectUser, + submit, + test, + waitForPOSTResponse, +} from "./helpers/playwright"; + +const TOURNAMENT_ID = 1; +const auditPage = `${tournamentAdminPage(TOURNAMENT_ID)}/audit`; + +test.describe("Tournament admin team management", () => { + test("edits a registration, checks a team in and out, unregisters it and records it in the audit log", async ({ + page, + }) => { + await seed(page); + await impersonate(page); + + // --- Edit registration: rename the first team --- + await navigate({ + page, + url: tournamentAdminRegistrationEditPage(TOURNAMENT_ID, 1), + }); + await expect( + page.getByRole("heading", { name: "Edit registration" }), + ).toBeVisible(); + + await page.getByLabel("Team name").fill("Renamed Team"); + await submit(page); + + // back on the team list, the rename is reflected + await expect(page.getByLabel("Search teams")).toBeVisible(); + await expect( + page.getByTestId("team-name").filter({ hasText: "Renamed Team" }), + ).toBeVisible(); + + const firstRowActions = page + .getByTestId("team-row") + .first() + .getByLabel("Actions"); + + // --- Check the team in (fetcher JSON submit fired from the menu) --- + await firstRowActions.click(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("menuitem", { name: /^Check in/ }).click(); + }); + + // --- Check the team out --- + await firstRowActions.click(); + await waitForPOSTResponse(page, async () => { + await page.getByRole("menuitem", { name: /^Check out/ }).click(); + }); + + // --- Unregister the team (confirm dialog) --- + await firstRowActions.click(); + await page.getByRole("menuitem", { name: "Unregister" }).click(); + await expect( + page.getByRole("heading", { + name: /Unregister .* and delete its registration info\?/, + }), + ).toBeVisible(); + await modalClickConfirmButton(page); + + // --- Audit log records the actions (target table cells, not the + // event-filter