From 9312fad90fcccef3ae06c4b35c323359e62cfb0e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 25 Jul 2024 23:06:29 +0300 Subject: [PATCH] Tournament organization page (#1811) * Initial * Calendar initial * Extract EventCalendar * Events list initial * Winners * SQL fixes * List events by series * Leaderboards * Series leaderboard * Own entry peek * Edit page skeleton * RHF initial test * RHF stuff * Form etc. progress * Fix tournament series description * Fix tabs layout * Fix socials insert * Check for not removing admin * Adding series * TODOs * Allow updating org with no series * FormFieldset * Allow series without events * TextAreaFormfield accepting array syntax * Input form array field * ToggleFormField * SelectFormField * UserSearchFormField * Fetch badgeOptions * Badge editing * Progress * Use native preventScrollReset * Rename func * Fix sticky scroll * Fix translation * i18n errors * handle,meta in edit * Add ref to user search * TODOs * Done --- app/components/Button.tsx | 8 +- .../ConditionalScrollRestoration.tsx | 25 - app/components/Label.tsx | 2 +- app/components/NewTabs.tsx | 2 + app/components/UserSearch.tsx | 246 ++++---- app/components/form/AddFieldButton.tsx | 24 + app/components/form/FormFieldset.tsx | 21 + app/components/form/MyForm.tsx | 56 ++ app/components/form/RemoveFieldButton.tsx | 14 + app/components/form/SelectFormField.tsx | 45 ++ app/components/form/TextAreaFormField.tsx | 46 ++ app/components/form/TextArrayFormField.tsx | 72 +++ app/components/form/TextFormField.tsx | 33 + app/components/form/ToggleFormField.tsx | 41 ++ app/components/form/UserSearchFormField.tsx | 46 ++ app/db/tables.ts | 44 ++ app/features/admin/actions/admin.server.ts | 4 +- app/features/api-private/routes/seed.tsx | 4 +- app/features/art/routes/art.new.tsx | 4 +- app/features/badges/BadgeRepository.server.ts | 10 + app/features/badges/badges-utils.ts | 24 + .../badges/components/BadgeDisplay.tsx | 80 +++ .../badges/routes/badges.$id.edit.tsx | 4 +- app/features/badges/routes/badges.$id.tsx | 25 +- app/features/build-analyzer/analyzer-hooks.ts | 2 +- app/features/builds/routes/builds.$slug.tsx | 2 +- .../calendar/CalendarRepository.server.ts | 37 +- .../calendar/actions/calendar.new.server.ts | 2 + .../calendar/loaders/calendar.new.server.ts | 4 + app/features/calendar/routes/calendar.new.tsx | 27 + app/features/calendar/routes/calendar.tsx | 33 +- .../img-upload/actions/upload.server.ts | 118 ++++ .../img-upload/queries/addNewImage.ts | 12 +- .../img-upload/routes/upload.admin.tsx | 4 +- app/features/img-upload/routes/upload.tsx | 117 +--- app/features/img-upload/upload-constants.ts | 4 +- app/features/lfg/actions/lfg.new.server.ts | 4 +- app/features/lfg/actions/lfg.server.ts | 4 +- .../map-list-generator/routes/maps.tsx | 4 +- .../calculator-hooks.ts | 2 +- ...plus.suggestions.comment.$tier.$userId.tsx | 4 +- .../routes/plus.suggestions.new.tsx | 4 +- .../routes/plus.suggestions.tsx | 4 +- .../plus-voting/routes/plus.voting.tsx | 4 +- .../sendouq-settings/routes/q.settings.tsx | 4 +- app/features/sendouq/routes/q.looking.tsx | 4 +- app/features/sendouq/routes/q.match.$id.tsx | 4 +- app/features/sendouq/routes/q.preparing.tsx | 4 +- app/features/sendouq/routes/q.tsx | 4 +- app/features/team/actions/t.server.ts | 4 +- .../team/routes/t.$customUrl.edit.tsx | 8 +- .../team/routes/t.$customUrl.roster.tsx | 4 +- .../tournament-bracket/core/Tournament.ts | 42 +- .../tournament-bracket/core/tests/mocks.ts | 3 + .../core/tests/test-utils.ts | 1 + .../routes/to.$id.brackets.tsx | 4 +- .../routes/to.$id.matches.$mid.tsx | 4 +- ...TournamentOrganizationRepository.server.ts | 403 +++++++++++++ .../actions/org.$slug.edit.server.ts | 52 ++ .../components/EventCalendar.tsx | 138 +++++ .../components/SocialLinksList.tsx | 68 +++ .../core/leaderboards.server.ts | 162 +++++ .../loaders/org.$slug.edit.server.ts | 20 + .../loaders/org.$slug.server.ts | 129 ++++ .../routes/org.$slug.edit.tsx | 370 ++++++++++++ .../routes/org.$slug.tsx | 568 ++++++++++++++++++ .../tournament-organization-constants.ts | 2 + .../tournament-organization-utils.server.ts | 17 + .../tournament-organization-utils.ts | 22 + .../tournament-organization.css | 183 ++++++ .../routes/to.$id.subs.new.tsx | 4 +- .../tournament-subs/routes/to.$id.subs.tsx | 4 +- .../tournament/TournamentRepository.server.ts | 52 ++ .../tournament/routes/to.$id.admin.tsx | 4 +- .../tournament/routes/to.$id.join.tsx | 4 +- .../tournament/routes/to.$id.register.tsx | 69 ++- .../tournament/routes/to.$id.seeds.tsx | 4 +- app/features/tournament/routes/to.$id.tsx | 23 +- .../u.$identifier.builds.new.server.ts | 4 +- .../actions/u.$identifier.builds.server.ts | 4 +- .../user-page/routes/u.$identifier.art.tsx | 4 +- .../user-page/routes/u.$identifier.index.tsx | 58 +- .../u.$identifier.results.highlights.tsx | 4 +- app/features/vods/routes/vods.new.tsx | 4 +- app/root.tsx | 5 +- app/styles/common.css | 46 ++ app/styles/u.css | 41 -- app/styles/utils.css | 4 + app/utils/dates.ts | 22 + app/utils/form.ts | 21 + app/utils/remix.ts | 48 +- app/utils/types.ts | 4 + app/utils/urls.ts | 23 +- locales/da/org.json | 1 + locales/de/org.json | 1 + locales/en/common.json | 1 + locales/en/org.json | 26 + locales/es-ES/org.json | 1 + locales/es-US/org.json | 1 + locales/fr-CA/org.json | 1 + locales/fr-EU/org.json | 1 + locales/he/org.json | 1 + locales/it/org.json | 1 + locales/ja/org.json | 1 + locales/ko/org.json | 1 + locales/nl/org.json | 1 + locales/pl/org.json | 1 + locales/pt-BR/org.json | 1 + locales/ru/org.json | 1 + locales/zh/org.json | 1 + migrations/065-tournament-orgs.js | 72 +++ package-lock.json | 37 ++ package.json | 3 + scripts/add-tournament-organization.ts | 22 + scripts/mass-add-tournaments-to-org.ts | 43 ++ types/react-i18next.d.ts | 2 + vite.config.ts | 9 + 117 files changed, 3688 insertions(+), 494 deletions(-) delete mode 100644 app/components/ConditionalScrollRestoration.tsx create mode 100644 app/components/form/AddFieldButton.tsx create mode 100644 app/components/form/FormFieldset.tsx create mode 100644 app/components/form/MyForm.tsx create mode 100644 app/components/form/RemoveFieldButton.tsx create mode 100644 app/components/form/SelectFormField.tsx create mode 100644 app/components/form/TextAreaFormField.tsx create mode 100644 app/components/form/TextArrayFormField.tsx create mode 100644 app/components/form/TextFormField.tsx create mode 100644 app/components/form/ToggleFormField.tsx create mode 100644 app/components/form/UserSearchFormField.tsx create mode 100644 app/features/badges/badges-utils.ts create mode 100644 app/features/badges/components/BadgeDisplay.tsx create mode 100644 app/features/img-upload/actions/upload.server.ts create mode 100644 app/features/tournament-organization/TournamentOrganizationRepository.server.ts create mode 100644 app/features/tournament-organization/actions/org.$slug.edit.server.ts create mode 100644 app/features/tournament-organization/components/EventCalendar.tsx create mode 100644 app/features/tournament-organization/components/SocialLinksList.tsx create mode 100644 app/features/tournament-organization/core/leaderboards.server.ts create mode 100644 app/features/tournament-organization/loaders/org.$slug.edit.server.ts create mode 100644 app/features/tournament-organization/loaders/org.$slug.server.ts create mode 100644 app/features/tournament-organization/routes/org.$slug.edit.tsx create mode 100644 app/features/tournament-organization/routes/org.$slug.tsx create mode 100644 app/features/tournament-organization/tournament-organization-constants.ts create mode 100644 app/features/tournament-organization/tournament-organization-utils.server.ts create mode 100644 app/features/tournament-organization/tournament-organization-utils.ts create mode 100644 app/features/tournament-organization/tournament-organization.css create mode 100644 app/utils/form.ts create mode 100644 locales/da/org.json create mode 100644 locales/de/org.json create mode 100644 locales/en/org.json create mode 100644 locales/es-ES/org.json create mode 100644 locales/es-US/org.json create mode 100644 locales/fr-CA/org.json create mode 100644 locales/fr-EU/org.json create mode 100644 locales/he/org.json create mode 100644 locales/it/org.json create mode 100644 locales/ja/org.json create mode 100644 locales/ko/org.json create mode 100644 locales/nl/org.json create mode 100644 locales/pl/org.json create mode 100644 locales/pt-BR/org.json create mode 100644 locales/ru/org.json create mode 100644 locales/zh/org.json create mode 100644 migrations/065-tournament-orgs.js create mode 100644 scripts/add-tournament-organization.ts create mode 100644 scripts/mass-add-tournaments-to-org.ts diff --git a/app/components/Button.tsx b/app/components/Button.tsx index cd9ca2869..d63b7361d 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -67,7 +67,9 @@ type LinkButtonProps = Pick< ButtonProps, "variant" | "children" | "className" | "size" | "testId" | "icon" > & - Pick & { "data-cy"?: string } & { + Pick & { + "data-cy"?: string; + } & { isExternal?: boolean; }; @@ -79,9 +81,9 @@ export function LinkButton({ to, prefetch, isExternal, - state, testId, icon, + preventScrollReset, }: LinkButtonProps) { if (isExternal) { return ( @@ -119,7 +121,7 @@ export function LinkButton({ to={to} data-testid={testId} prefetch={prefetch} - state={state} + preventScrollReset={preventScrollReset} > {icon && React.cloneElement(icon, { diff --git a/app/components/ConditionalScrollRestoration.tsx b/app/components/ConditionalScrollRestoration.tsx deleted file mode 100644 index 66d8e2d6f..000000000 --- a/app/components/ConditionalScrollRestoration.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// temporary workaround before Remix has React Router 6.4 -// https://github.com/remix-run/remix/issues/186#issuecomment-1178395835 - -import { ScrollRestoration, useLocation } from "@remix-run/react"; -import * as React from "react"; - -export function ConditionalScrollRestoration() { - const isFirstRenderRef = React.useRef(true); - const location = useLocation(); - - React.useEffect(() => { - isFirstRenderRef.current = false; - }, []); - - if ( - !isFirstRenderRef.current && - location.state != null && - typeof location.state === "object" && - (location.state as { scroll: boolean }).scroll === false - ) { - return null; - } - - return location.pathname} />; -} diff --git a/app/components/Label.tsx b/app/components/Label.tsx index fc13eedfb..89382874b 100644 --- a/app/components/Label.tsx +++ b/app/components/Label.tsx @@ -41,7 +41,7 @@ export function Label({ } function lengthWarning(valueLimits: NonNullable) { - if (valueLimits.current >= valueLimits.max) return "error"; + if (valueLimits.current > valueLimits.max) return "error"; if (valueLimits.current / valueLimits.max >= 0.9) return "warning"; return; diff --git a/app/components/NewTabs.tsx b/app/components/NewTabs.tsx index 16224ab38..1c7a87338 100644 --- a/app/components/NewTabs.tsx +++ b/app/components/NewTabs.tsx @@ -7,6 +7,7 @@ interface NewTabsProps { label: string; number?: number; hidden?: boolean; + disabled?: boolean; }[]; content: { key: string; @@ -57,6 +58,7 @@ export function NewTabs(args: NewTabsProps) { key={tab.label} className="tab__button" data-testid={`tab-${tab.label}`} + disabled={tab.disabled} > {tab.label} {typeof tab.number === "number" && tab.number !== 0 && ( diff --git a/app/components/UserSearch.tsx b/app/components/UserSearch.tsx index 4c6371dbc..765029fb3 100644 --- a/app/components/UserSearch.tsx +++ b/app/components/UserSearch.tsx @@ -9,130 +9,140 @@ import { Avatar } from "./Avatar"; type UserSearchUserItem = NonNullable["users"][number]; -export function UserSearch({ - inputName, - onChange, - initialUserId, - id, - className, - userIdsToOmit, - required, -}: { - inputName: string; - onChange?: (user: UserSearchUserItem) => void; - initialUserId?: number; - id?: string; - className?: string; - userIdsToOmit?: Set; - required?: boolean; -}) { - const { t } = useTranslation(); - const [selectedUser, setSelectedUser] = - React.useState(null); - const queryFetcher = useFetcher(); - const initialUserFetcher = useFetcher(); - const [query, setQuery] = React.useState(""); - useDebounce( - () => { - if (!query) return; - - queryFetcher.load(`/u?q=${query}&limit=6`); +export const UserSearch = React.forwardRef< + HTMLInputElement, + { + inputName?: string; + onChange?: (user: UserSearchUserItem) => void; + initialUserId?: number; + id?: string; + className?: string; + userIdsToOmit?: Set; + required?: boolean; + onBlur?: React.FocusEventHandler; + } +>( + ( + { + inputName, + onChange, + initialUserId, + id, + className, + userIdsToOmit, + required, + onBlur, }, - 1000, - [query], - ); + ref, + ) => { + const { t } = useTranslation(); + const [selectedUser, setSelectedUser] = + React.useState(null); + const queryFetcher = useFetcher(); + const initialUserFetcher = useFetcher(); + const [query, setQuery] = React.useState(""); - // load initial user - React.useEffect(() => { - if ( - !initialUserId || - initialUserFetcher.state !== "idle" || - initialUserFetcher.data - ) { - return; - } + useDebounce( + () => { + if (!query) return; + queryFetcher.load(`/u?q=${query}&limit=6`); + }, + 1000, + [query], + ); - initialUserFetcher.load(`/u?q=${initialUserId}`); - }, [initialUserId, initialUserFetcher]); - React.useEffect(() => { - if (!initialUserFetcher.data) return; + React.useEffect(() => { + if ( + !initialUserId || + initialUserFetcher.state !== "idle" || + initialUserFetcher.data + ) { + return; + } + initialUserFetcher.load(`/u?q=${initialUserId}`); + }, [initialUserId, initialUserFetcher]); - setSelectedUser(initialUserFetcher.data.users[0]); - }, [initialUserFetcher.data]); + React.useEffect(() => { + if (!initialUserFetcher.data) return; + setSelectedUser(initialUserFetcher.data.users[0]); + }, [initialUserFetcher.data]); - const allUsers = queryFetcher.data?.users ?? []; + const allUsers = queryFetcher.data?.users ?? []; + const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id)); + const noMatches = queryFetcher.data && users.length === 0; + const initialSelectionIsLoading = Boolean( + initialUserId && !initialUserFetcher.data, + ); - const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id)); - const noMatches = queryFetcher.data && users.length === 0; - - const initialSelectionIsLoading = Boolean( - initialUserId && !initialUserFetcher.data, - ); - - return ( -
- {selectedUser && inputName ? ( - - ) : null} - { - setSelectedUser(newUser); - onChange?.(newUser!); - }} - disabled={initialSelectionIsLoading} - > - setQuery(event.target.value)} - displayValue={(user: UserSearchUserItem) => user?.username ?? ""} - className={clsx("combobox-input", className)} - data-1p-ignore - data-testid={`${inputName}-combobox-input`} - id={id} - required={required} - /> - + {selectedUser && inputName ? ( + + ) : null} + { + setSelectedUser(newUser); + onChange?.(newUser!); + }} + disabled={initialSelectionIsLoading} > - {noMatches ? ( -
- {t("forms.errors.noSearchMatches")}{" "} - 🤔 -
- ) : null} - {users.map((user, i) => ( - - {({ active }) => ( -
  • - -
    -
    - {user.username}{" "} - {user.plusTier ? ( - +{user.plusTier} + setQuery(event.target.value)} + displayValue={(user: UserSearchUserItem) => user?.username ?? ""} + className={clsx("combobox-input", className)} + data-1p-ignore + data-testid={`${inputName}-combobox-input`} + id={id} + required={required} + onBlur={onBlur} + /> + + {noMatches ? ( +
    + {t("forms.errors.noSearchMatches")}{" "} + 🤔 +
    + ) : null} + {users.map((user, i) => ( + + {({ active }) => ( +
  • + +
    +
    + + {user.username} + {" "} + {user.plusTier ? ( + +{user.plusTier} + ) : null} +
    + {user.discordUniqueName ? ( +
    {user.discordUniqueName}
    ) : null}
    - {user.discordUniqueName ? ( -
    {user.discordUniqueName}
    - ) : null} -
  • - - )} - - ))} - - - - ); -} + + )} + + ))} + + + + ); + }, +); diff --git a/app/components/form/AddFieldButton.tsx b/app/components/form/AddFieldButton.tsx new file mode 100644 index 000000000..d7c1f47ef --- /dev/null +++ b/app/components/form/AddFieldButton.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "../Button"; +import { PlusIcon } from "../icons/Plus"; + +export function AddFieldButton({ + onClick, +}: { + onClick: () => void; +}) { + const { t } = useTranslation(["common"]); + + return ( + + ); +} diff --git a/app/components/form/FormFieldset.tsx b/app/components/form/FormFieldset.tsx new file mode 100644 index 000000000..0d2edecde --- /dev/null +++ b/app/components/form/FormFieldset.tsx @@ -0,0 +1,21 @@ +import type * as React from "react"; +import { RemoveFieldButton } from "./RemoveFieldButton"; + +export function FormFieldset({ + title, + children, + onRemove, +}: { title: string; children: React.ReactNode; onRemove: () => void }) { + return ( +
    + {title} +
    + {children} + +
    + +
    +
    +
    + ); +} diff --git a/app/components/form/MyForm.tsx b/app/components/form/MyForm.tsx new file mode 100644 index 000000000..5dbf6e30f --- /dev/null +++ b/app/components/form/MyForm.tsx @@ -0,0 +1,56 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useFetcher } from "@remix-run/react"; +import * as React from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import type { z } from "zod"; +import type { ActionError } from "~/utils/remix"; +import { SubmitButton } from "../SubmitButton"; + +export function MyForm({ + schema, + defaultValues, + title, + children, +}: { + schema: T; + defaultValues?: z.infer; + title?: string; + children: React.ReactNode; +}) { + const { t } = useTranslation(["common"]); + const fetcher = useFetcher(); + const methods = useForm>({ + resolver: zodResolver(schema), + defaultValues, + }); + + React.useEffect(() => { + if (!fetcher.data?.isError) return; + + const error = fetcher.data as ActionError; + + methods.setError(error.field as any, { + message: error.msg, + }); + }, [fetcher.data, methods.setError]); + + const onSubmit = React.useCallback( + methods.handleSubmit((values) => + fetcher.submit(values, { method: "post", encType: "application/json" }), + ), + [], + ); + + return ( + + + {title ?

    {title}

    : null} + {children} + + {t("common:actions.submit")} + +
    +
    + ); +} diff --git a/app/components/form/RemoveFieldButton.tsx b/app/components/form/RemoveFieldButton.tsx new file mode 100644 index 000000000..704d62692 --- /dev/null +++ b/app/components/form/RemoveFieldButton.tsx @@ -0,0 +1,14 @@ +import { Button } from "../Button"; +import { TrashIcon } from "../icons/Trash"; + +export function RemoveFieldButton({ onClick }: { onClick: () => void }) { + return ( +