diff --git a/app/components/Button.tsx b/app/components/Button.tsx index d68b072b1..cc6faca46 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -6,6 +6,7 @@ import * as React from "react"; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: + | "primary" | "success" | "outlined" | "outlined-success" diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 75f422f89..4e20361c0 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -85,6 +85,17 @@ export function Catcher() { )} ); + case 403: + return ( +
+

Error 403 Forbidden

+

+ Your account doesn't have the required permissions to perform this + action. +

+ +
+ ); case 404: return (
diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx index a103214d2..0723e5a93 100644 --- a/app/components/FormWithConfirm.tsx +++ b/app/components/FormWithConfirm.tsx @@ -12,11 +12,12 @@ export function FormWithConfirm({ fields, children, dialogHeading, - deleteButtonText, + submitButtonText, cancelButtonText, action, submitButtonTestId = "submit-button", submitButtonVariant = "destructive", + cancelButtonVariant, fetcher: _fetcher, }: { fields?: ( @@ -25,11 +26,12 @@ export function FormWithConfirm({ )[]; children: React.ReactNode; dialogHeading: string; - deleteButtonText?: string; + submitButtonText?: string; cancelButtonText?: string; action?: string; submitButtonTestId?: string; submitButtonVariant?: ButtonProps["variant"]; + cancelButtonVariant?: ButtonProps["variant"]; fetcher?: FetcherWithComponents; }) { const componentsFetcher = useFetcher(); @@ -80,9 +82,9 @@ export function FormWithConfirm({ variant={submitButtonVariant} testId={dialogOpen ? "confirm-button" : submitButtonTestId} > - {deleteButtonText ?? t("common:actions.delete")} + {submitButtonText ?? t("common:actions.delete")} - diff --git a/app/components/NewTabs.tsx b/app/components/NewTabs.tsx index 5e59b4dcd..fde1d004c 100644 --- a/app/components/NewTabs.tsx +++ b/app/components/NewTabs.tsx @@ -18,6 +18,7 @@ interface NewTabsProps { }[]; scrolling?: boolean; selectedIndex?: number; + defaultIndex?: number; setSelectedIndex?: (index: number) => void; /** Don't take space when no tabs to show? */ disappearing?: boolean; @@ -40,6 +41,7 @@ export function NewTabs(args: NewTabsProps) { scrolling = true, selectedIndex, setSelectedIndex, + defaultIndex, disappearing = false, padded = true, } = args; @@ -47,7 +49,11 @@ export function NewTabs(args: NewTabsProps) { const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1; return ( - + ; required?: boolean; onBlur?: React.FocusEventHandler; + disabled?: boolean; } >( ( @@ -32,6 +33,7 @@ export const UserSearch = React.forwardRef< userIdsToOmit, required, onBlur, + disabled, }, ref, ) => { @@ -85,7 +87,7 @@ export const UserSearch = React.forwardRef< setSelectedUser(newUser); onChange?.(newUser!); }} - disabled={initialSelectionIsLoading} + disabled={disabled || initialSelectionIsLoading} > .buttonIcon { + width: 1rem; + margin-inline-end: var(--s-1); +} + +.miniscule > .buttonIcon { + width: 0.857rem; + margin-inline-end: var(--s-1); +} diff --git a/app/components/elements/Button.tsx b/app/components/elements/Button.tsx index 2100ec346..859f6c651 100644 --- a/app/components/elements/Button.tsx +++ b/app/components/elements/Button.tsx @@ -4,16 +4,21 @@ import { Button as ReactAriaButton, type ButtonProps as ReactAriaButtonProps, } from "react-aria-components"; +import { assertUnreachable } from "~/utils/types"; +import styles from "./Button.module.css"; + +type ButtonVariant = + | "primary" + | "success" + | "outlined" + | "outlined-success" + | "destructive" + | "minimal" + | "minimal-success" + | "minimal-destructive"; interface MyDatePickerProps extends ReactAriaButtonProps { - variant?: - | "success" - | "outlined" - | "outlined-success" - | "destructive" - | "minimal" - | "minimal-success" - | "minimal-destructive"; + variant?: ButtonVariant; size?: "miniscule" | "small" | "medium" | "big"; icon?: JSX.Element; children?: React.ReactNode; @@ -27,27 +32,47 @@ export function SendouButton({ icon, ...rest }: MyDatePickerProps) { + const variantClassname = variant ? variantToClassname(variant) : null; + return ( {icon && React.cloneElement(icon, { - className: clsx(icon.props.className, "sendou-button-icon", { - lonely: !children, + className: clsx(icon.props.className, styles.buttonIcon, { + [styles.lonely]: !children, }), })} {children} ); } + +function variantToClassname(variant: ButtonVariant) { + switch (variant) { + case "primary": + return styles.primary; + case "success": + return styles.success; + case "outlined": + return styles.outlined; + case "outlined-success": + return styles.outlinedSuccess; + case "destructive": + return styles.destructive; + case "minimal": + return styles.minimal; + case "minimal-success": + return styles.minimalSuccess; + case "minimal-destructive": + return styles.minimalDestructive; + default: + return assertUnreachable(variant); + } +} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css new file mode 100644 index 000000000..07e4112a5 --- /dev/null +++ b/app/components/elements/Select.module.css @@ -0,0 +1,132 @@ +.button { + height: 1rem; + padding: var(--s-4) var(--s-3); + border: 2px solid var(--border); + border-radius: var(--rounded-sm); + accent-color: var(--theme-secondary); + background-color: var(--bg-input); + color: var(--text); + outline: none; + + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-1-5); + min-width: var(--select-width); + + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + letter-spacing: 0.5px; +} + +.button[data-focus-visible] { + outline: 2px solid var(--theme); +} + +.selectValue { + max-width: calc(var(--select-width) - 55px); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.selectValue[data-placeholder] { + color: var(--text-lighter); +} + +.icon { + min-width: 18px; + max-width: 18px; + stroke-width: 2.5px; + color: var(--text-lighter); +} + +.smallIcon { + min-width: 16px; + max-width: 16px; + stroke-width: 2px; + color: var(--text-lighter); +} + +.popover { + padding: var(--s-1); + min-width: var(--select-width); + max-width: var(--select-width); + border: 2px solid var(--border); + border-radius: var(--rounded); + background-color: var(--bg-darker); + max-height: 250px !important; + + display: flex; + flex-direction: column; +} + +.listBox { + overflow: auto; + flex: 1; +} + +.item { + font-size: var(--fonts-xsm); + font-weight: var(--semi-bold); + white-space: pre-wrap; + padding: var(--s-1-5); + border-radius: var(--rounded-sm); + height: 33px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.itemFocused { + background-color: var(--theme-transparent); + color: var(--text); +} + +.itemSelected { + color: var(--theme); + font-weight: var(--bold); +} + +.searchField { + display: flex; + gap: var(--s-2); + + border: 2px solid var(--border); + border-radius: var(--rounded-sm); + accent-color: var(--theme-secondary); + background-color: var(--bg-input); + color: var(--text); + outline: none; + padding: var(--s-1-5) var(--s-2); + margin-block-end: var(--s-1-5); +} + +.searchInput { + all: unset; + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + letter-spacing: 0.5px; + flex: 1; +} + +.searchInput::-webkit-search-cancel-button { + display: none; +} + +.searchInput::placeholder { + color: var(--text-lighter); +} + +.searchClearButton { + background-color: transparent; + border: none; +} + +.noResults { + font-size: var(--fonts-md); + font-weight: var(--bold); + text-align: center; + padding-block: var(--s-8); + color: var(--text-lighter); +} diff --git a/app/components/elements/Select.tsx b/app/components/elements/Select.tsx new file mode 100644 index 000000000..b38c93d32 --- /dev/null +++ b/app/components/elements/Select.tsx @@ -0,0 +1,114 @@ +import clsx from "clsx"; +import type { + ListBoxItemProps, + SelectProps, + ValidationResult, +} from "react-aria-components"; +import { + Autocomplete, + Button, + FieldError, + Input, + Label, + ListBox, + ListBoxItem, + ListLayout, + Popover, + SearchField, + Select, + SelectValue, + Text, + Virtualizer, + useFilter, +} from "react-aria-components"; +import { useTranslation } from "react-i18next"; +import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown"; +import { CrossIcon } from "../icons/Cross"; +import { SearchIcon } from "../icons/Search"; +import styles from "./Select.module.css"; + +interface SendouSelectProps + extends Omit, "children"> { + label?: string; + description?: string; + errorMessage?: string | ((validation: ValidationResult) => string); + items?: Iterable; + children: React.ReactNode | ((item: T) => React.ReactNode); + search?: { + placeholder?: string; + }; +} + +export function SendouSelect({ + label, + description, + errorMessage, + children, + items, + search, + ...props +}: SendouSelectProps) { + const { t } = useTranslation(["common"]); + const { contains } = useFilter({ sensitivity: "base" }); + + return ( + + + + ) : null} + + ( +
{t("common:noResults")}
+ )} + > + {children} +
+
+ + + + ); +} + +interface SendouSelectItemProps extends ListBoxItemProps {} + +export function SendouSelectItem(props: SendouSelectItemProps) { + return ( + + clsx(styles.item, { + [styles.itemFocused]: isFocused, + [styles.itemSelected]: isSelected, + }) + } + /> + ); +} diff --git a/app/components/form/DateTimeFormField.tsx b/app/components/form/DateTimeFormField.tsx new file mode 100644 index 000000000..cac1c6ff6 --- /dev/null +++ b/app/components/form/DateTimeFormField.tsx @@ -0,0 +1,33 @@ +import * as React from "react"; +import { + type FieldPath, + type FieldValues, + get, + useFormContext, +} from "react-hook-form"; +import { FormMessage } from "~/components/FormMessage"; +import { Label } from "~/components/Label"; + +export function DateTimeFormField({ + label, + name, + bottomText, +}: { label: string; name: FieldPath; bottomText?: string }) { + const methods = useFormContext(); + const id = React.useId(); + + const error = get(methods.formState.errors, name); + + return ( +
+ + + {error && ( + {error.message as string} + )} + {bottomText && !error ? ( + {bottomText} + ) : null} +
+ ); +} diff --git a/app/components/form/MyForm.tsx b/app/components/form/MyForm.tsx index af1a2f8b3..eefb9479b 100644 --- a/app/components/form/MyForm.tsx +++ b/app/components/form/MyForm.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import type { z } from "zod"; import { logger } from "~/utils/logger"; import type { ActionError } from "~/utils/remix.server"; +import { Button, LinkButton } from "../Button"; import { SubmitButton } from "../SubmitButton"; export function MyForm({ @@ -13,11 +14,15 @@ export function MyForm({ defaultValues, title, children, + handleCancel, + cancelLink, }: { schema: T; defaultValues?: DefaultValues>; title?: string; children: React.ReactNode; + handleCancel?: () => void; + cancelLink?: string; }) { const { t } = useTranslation(["common"]); const fetcher = useFetcher(); @@ -52,9 +57,29 @@ export function MyForm({ {title ?

{title}

: null} {children} - - {t("common:actions.submit")} - +
+ + {t("common:actions.submit")} + + {handleCancel ? ( + + ) : null} + {cancelLink ? ( + + {t("common:actions.cancel")} + + ) : null} +
); diff --git a/app/components/icons/ArrowDownOnSquare.tsx b/app/components/icons/ArrowDownOnSquare.tsx new file mode 100644 index 000000000..1326b17f2 --- /dev/null +++ b/app/components/icons/ArrowDownOnSquare.tsx @@ -0,0 +1,17 @@ +export function ArrowDownOnSquareIcon({ + className, +}: { + className?: string; +}) { + return ( + + Arrow Down On Square Icon + + + ); +} diff --git a/app/components/icons/ArrowUpOnSquare.tsx b/app/components/icons/ArrowUpOnSquare.tsx new file mode 100644 index 000000000..977577f51 --- /dev/null +++ b/app/components/icons/ArrowUpOnSquare.tsx @@ -0,0 +1,17 @@ +export function ArrowUpOnSquareIcon({ + className, +}: { + className?: string; +}) { + return ( + + Arrow Up On Square Icon + + + ); +} diff --git a/app/components/icons/ChevronUpDown.tsx b/app/components/icons/ChevronUpDown.tsx new file mode 100644 index 000000000..d3afb1c43 --- /dev/null +++ b/app/components/icons/ChevronUpDown.tsx @@ -0,0 +1,18 @@ +export function ChevronUpDownIcon({ className }: { className?: string }) { + return ( + + Chevron Up Down Icon + + + ); +} diff --git a/app/components/icons/MegaphoneIcon.tsx b/app/components/icons/MegaphoneIcon.tsx new file mode 100644 index 000000000..51fd8e815 --- /dev/null +++ b/app/components/icons/MegaphoneIcon.tsx @@ -0,0 +1,13 @@ +export function MegaphoneIcon({ className }: { className?: string }) { + return ( + + Megaphone Icon + + + ); +} diff --git a/app/components/icons/SpeechBubbleFilled.tsx b/app/components/icons/SpeechBubbleFilled.tsx new file mode 100644 index 000000000..01d726dc8 --- /dev/null +++ b/app/components/icons/SpeechBubbleFilled.tsx @@ -0,0 +1,17 @@ +export function SpeechBubbleFilledIcon({ className }: { className?: string }) { + return ( + + Speech Bubble Filled Icon + + + ); +} diff --git a/app/components/layout/AnythingAdder.tsx b/app/components/layout/AnythingAdder.tsx index 3856d17f7..63e30e000 100644 --- a/app/components/layout/AnythingAdder.tsx +++ b/app/components/layout/AnythingAdder.tsx @@ -2,6 +2,7 @@ import { useNavigate } from "@remix-run/react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useUser } from "~/features/auth/core/user"; +import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants"; import { CALENDAR_NEW_PAGE, NEW_TEAM_PAGE, @@ -9,6 +10,8 @@ import { lfgNewPostPage, navIconUrl, newArtPage, + newAssociationsPage, + newScrimPostPage, newVodPage, plusSuggestionsNewPage, userNewBuildPage, @@ -66,6 +69,22 @@ export function AnythingAdder() { imagePath: navIconUrl("t"), onClick: () => navigate(NEW_TEAM_PAGE), }, + FF_SCRIMS_ENABLED + ? { + id: "scrimPost", + text: t("header.adder.scrimPost"), + imagePath: navIconUrl("scrims"), + onClick: () => navigate(newScrimPostPage()), + } + : null, + FF_SCRIMS_ENABLED + ? { + id: "association", + text: t("header.adder.association"), + imagePath: navIconUrl("associations"), + onClick: () => navigate(newAssociationsPage()), + } + : null, { id: "lfgPost", text: t("header.adder.lfgPost"), @@ -90,7 +109,7 @@ export function AnythingAdder() { imagePath: navIconUrl("plus"), onClick: () => navigate(plusSuggestionsNewPage()), }, - ]; + ].filter((item) => item !== null); return ; } diff --git a/app/components/layout/nav-items.ts b/app/components/layout/nav-items.ts index 75e874599..44aa4ad02 100644 --- a/app/components/layout/nav-items.ts +++ b/app/components/layout/nav-items.ts @@ -1,3 +1,5 @@ +import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants"; + export const navItems = [ { name: "settings", @@ -36,6 +38,13 @@ export const navItems = [ url: "leaderboards", prefetch: false, }, + FF_SCRIMS_ENABLED + ? { + name: "scrims", + url: "scrims", + prefetch: false, + } + : null, { name: "lfg", url: "lfg", diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 01b107682..97f342753 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1,10 +1,11 @@ import { faker } from "@faker-js/faker"; -import { sub } from "date-fns"; +import { add, sub } from "date-fns"; import { nanoid } from "nanoid"; import * as R from "remeda"; import { ADMIN_DISCORD_ID, ADMIN_ID, INVITE_CODE_LENGTH } from "~/constants"; import { db, sql } from "~/db/sql"; import type { SeedVariation } from "~/features/api-private/routes/seed"; +import * as AssociationRepository from "~/features/associations/AssociationRepository.server"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; import { persistedTags } from "~/features/calendar/calendar-constants"; @@ -20,6 +21,7 @@ import { nextNonCompletedVoting, rangeToMonthYear, } from "~/features/plus-voting/core"; +import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server"; import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server"; import { calculateMatchSkills } from "~/features/sendouq-match/core/skills.server"; import { @@ -66,7 +68,7 @@ import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants"; import { nullFilledArray } from "~/utils/arrays"; -import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { mySlugify } from "~/utils/urls"; import type { Tables, UserMapModePreferences } from "../tables"; @@ -170,6 +172,9 @@ const basicSeeds = (variation?: SeedVariation | null) => [ groups, friendCodes, lfgPosts, + scrimPosts, + scrimPostRequests, + associations, notifications, ]; @@ -190,6 +195,8 @@ export async function seed(variation?: SeedVariation | null) { function wipeDB() { const tablesToDelete = [ + "ScrimPost", + "Association", "LFGPost", "Skill", "ReportedWeapon", @@ -2283,6 +2290,144 @@ async function lfgPosts() { }); } +async function scrimPosts() { + const allUsers = userIdsInRandomOrder(true); + + const date = () => { + const isNow = Math.random() > 0.5; + + if (isNow) { + return databaseTimestampNow(); + } + + const randomFuture = faker.date.between({ + from: new Date(), + to: add(new Date(), { days: 7 }), + }); + + randomFuture.setMinutes(0); + randomFuture.setSeconds(0); + randomFuture.setMilliseconds(0); + + return dateToDatabaseTimestamp(randomFuture); + }; + + const team = () => { + const hasTeam = Math.random() > 0.5; + + if (!hasTeam) { + return null; + } + + return faker.helpers.rangeToNumber({ min: 5, max: 49 }); + }; + + const divRange = () => { + const hasDivRange = Math.random() > 0.2; + + if (!hasDivRange) { + return null; + } + + const maxDiv = faker.helpers.arrayElement([0, 1, 2, 3, 4, 5]); + const minDiv = faker.helpers.arrayElement([6, 7, 8, 9, 10, 11]); + + return { maxDiv, minDiv }; + }; + + const users = () => { + const count = faker.helpers.arrayElement([4, 4, 4, 4, 4, 4, 5, 5, 5, 6]); + + const result: Array<{ userId: number; isOwner: number }> = []; + for (let i = 0; i < count; i++) { + const user = allUsers.shift()!; + + result.push({ + userId: user, + isOwner: Number(i === 0), + }); + } + + return result; + }; + + for (let i = 0; i < 20; i++) { + const divs = divRange(); + + await ScrimPostRepository.insert({ + at: date(), + maxDiv: divs?.maxDiv, + minDiv: divs?.minDiv, + teamId: team(), + text: + Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null, + visibility: null, + users: users(), + }); + } + + const adminPostId = await ScrimPostRepository.insert({ + at: date(), + text: + Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null, + visibility: null, + users: users() + .map((u) => ({ ...u, isOwner: 0 })) + .concat({ userId: ADMIN_ID, isOwner: 1 }), + }); + await ScrimPostRepository.insertRequest({ + scrimPostId: adminPostId, + users: users(), + }); + await ScrimPostRepository.insertRequest({ + scrimPostId: adminPostId, + users: users(), + }); +} + +async function scrimPostRequests() { + const allianceRogueMembers = await db + .selectFrom(["TeamMember"]) + .select(["TeamMember.userId"]) + .where("TeamMember.teamId", "=", 1) + .execute(); + + for (const id of [1, 5, 12, 14, 19]) { + await ScrimPostRepository.insertRequest({ + scrimPostId: id, + users: allianceRogueMembers.map((member) => ({ + userId: member.userId, + isOwner: member.userId === ADMIN_ID ? 1 : 0, + })), + teamId: 1, + }); + } + + await ScrimPostRepository.acceptRequest(3); +} + +async function associations() { + const allUsers = userIdsInRandomOrder(true); + + for (let i = 0; i < 3; i++) { + await AssociationRepository.insert({ + name: faker.company.name(), + userId: i === 2 ? allUsers.shift()! : ADMIN_ID, + }); + + for ( + let j = 0; + j < faker.helpers.arrayElement([4, 6, 8, 10, 12, 24, 32]); + j++ + ) { + await AssociationRepository.addMember({ + associationId: i + 1, + userId: i === 2 && j === 0 ? ADMIN_ID : allUsers.shift()!, + }); + } + } +} + async function notifications() { const values: Notification[] = [ { diff --git a/app/db/tables.ts b/app/db/tables.ts index ced1b0a46..623c72b84 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -6,6 +6,7 @@ import type { SqlBool, Updateable, } from "kysely"; +import type { AssociationVisibility } from "~/features/associations/associations-types"; import type { persistedTags, tags, @@ -807,6 +808,7 @@ export type BuildSort = (typeof BUILD_SORT_IDENTIFIERS)[number]; export interface UserPreferences { disableBuildAbilitySorting?: boolean; + disallowScrimPickupsFromUntrusted?: boolean; } export interface User { @@ -932,6 +934,65 @@ export interface XRankPlacement { year: number; } +export interface ScrimPost { + id: GeneratedAlways; + /** When is the scrim scheduled to happen */ + at: number; + /** Highest LUTI div accepted */ + maxDiv: number | null; + /** Lowest LUTI div accepted */ + minDiv: number | null; + /** Who sees the post */ + visibility: ColumnType< + AssociationVisibility | null, + string | null, + string | null + >; + /** Any additional info */ + text: string | null; + /** The key to access the scrim chat, used after scrim is scheduled with another team */ + chatCode: string; + /** Refers to the team looking for the team (can also be a pick-up) */ + teamId: number | null; + createdAt: GeneratedAlways; + updatedAt: Generated; +} + +export interface ScrimPostUser { + scrimPostId: number; + userId: number; + /** User is the author of the post */ + isOwner: number; +} + +export interface ScrimPostRequest { + id: GeneratedAlways; + scrimPostId: number; + teamId: number | null; + isAccepted: Generated; + createdAt: GeneratedAlways; +} + +export interface ScrimPostRequestUser { + scrimPostRequestId: number; + userId: number; + /** User made the request */ + isOwner: number; +} + +export interface Association { + id: GeneratedAlways; + name: string; + inviteCode: string; + createdAt: GeneratedAlways; +} + +export interface AssociationMember { + userId: number; + associationId: number; + role: "MEMBER" | "ADMIN"; +} + export interface Notification { id: GeneratedAlways; type: NotificationValue["type"]; @@ -1042,6 +1103,12 @@ export interface DB { VideoMatch: VideoMatch; VideoMatchPlayer: VideoMatchPlayer; XRankPlacement: XRankPlacement; + ScrimPost: ScrimPost; + ScrimPostUser: ScrimPostUser; + ScrimPostRequest: ScrimPostRequest; + ScrimPostRequestUser: ScrimPostRequestUser; + Association: Association; + AssociationMember: AssociationMember; Notification: Notification; NotificationUser: NotificationUser; NotificationUserSubscription: NotificationUserSubscription; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index 35ed2197e..41220c03e 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -234,7 +234,7 @@ function ImagePreview({ ["id", art.id], ["_action", "UNLINK_ART"], ]} - deleteButtonText={t("common:actions.remove")} + submitButtonText={t("common:actions.remove")} > + + ) : !request.isAccepted && !canAccept ? ( + + {t("common:actions.accept")} + + } + > + {t("scrims:acceptModal.prevented")} + + ) : ( + + )} + + + ); +} diff --git a/app/features/scrims/scrims-constants.ts b/app/features/scrims/scrims-constants.ts new file mode 100644 index 000000000..7531e42d4 --- /dev/null +++ b/app/features/scrims/scrims-constants.ts @@ -0,0 +1,20 @@ +export const LUTI_DIVS = [ + "X", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", +] as const; + +export const SCRIM = { + MAX_PICKUP_SIZE_EXCLUDING_OWNER: 5, +}; + +export const FF_SCRIMS_ENABLED = process.env.NODE_ENV === "development"; diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts new file mode 100644 index 000000000..c34a6f675 --- /dev/null +++ b/app/features/scrims/scrims-schemas.ts @@ -0,0 +1,180 @@ +import { add, sub } from "date-fns"; +import { z } from "zod"; +import { + _action, + date, + falsyToNull, + filterOutNullishMembers, + id, + noDuplicates, +} from "~/utils/zod"; +import { associationIdentifierSchema } from "../associations/associations-schemas"; +import { LUTI_DIVS, SCRIM } from "./scrims-constants"; + +export const deletePostSchema = z.object({ + _action: _action("DELETE_POST"), + scrimPostId: id, +}); + +const fromUsers = z.preprocess( + filterOutNullishMembers, + z + .array(id) + .min(3, { + message: "Must have at least 3 users excluding yourself", + }) + .max(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER) + .refine(noDuplicates, { + message: "Users must be unique", + }), +); + +export const fromSchema = z.union([ + z.object({ mode: z.literal("PICKUP"), users: fromUsers }), + z.object({ mode: z.literal("TEAM"), teamId: id }), +]); + +export const newRequestSchema = z.object({ + _action: _action("NEW_REQUEST"), + scrimPostId: id, + from: fromSchema, +}); + +export const acceptRequestSchema = z.object({ + _action: _action("ACCEPT_REQUEST"), + scrimPostRequestId: id, +}); + +export const cancelRequestSchema = z.object({ + _action: _action("CANCEL_REQUEST"), + scrimPostRequestId: id, +}); + +export const scrimsActionSchema = z.union([ + deletePostSchema, + newRequestSchema, + acceptRequestSchema, + cancelRequestSchema, +]); + +export const MAX_SCRIM_POST_TEXT_LENGTH = 500; + +export const scrimsNewActionSchema = z + .object({ + at: z.preprocess( + date, + z + .date() + .refine( + (date) => { + if (date < sub(new Date(), { days: 1 })) return false; + + return true; + }, + { + message: "Date can not be in the past", + }, + ) + .refine( + (date) => { + if (date > add(new Date(), { days: 15 })) return false; + + return true; + }, + { + message: "Date can not be more than 2 weeks in the future", + }, + ), + ), + baseVisibility: associationIdentifierSchema, + notFoundVisibility: z.object({ + at: z + .preprocess(date, z.date()) + .nullish() + .refine( + (date) => { + if (!date) return true; + + if (date < sub(new Date(), { days: 1 })) return false; + + return true; + }, + { + message: "Date can not be in the past", + }, + ), + forAssociation: associationIdentifierSchema, + }), + divs: z + .object({ + min: z.enum(LUTI_DIVS).nullable(), + max: z.enum(LUTI_DIVS).nullable(), + }) + .nullable() + .refine( + (div) => { + if (!div) return true; + + if (div.max && !div.min) return false; + if (div.min && !div.max) return false; + + return true; + }, + { + message: "Both min and max div must be set or neither", + }, + ) + .refine( + (divs) => { + if (!divs?.min || !divs.max) return true; + + const minIndex = LUTI_DIVS.indexOf(divs.min); + const maxIndex = LUTI_DIVS.indexOf(divs.max); + + return minIndex >= maxIndex; + }, + { message: "Min div must be less than or equal to max div" }, + ), + from: fromSchema, + postText: z.preprocess( + falsyToNull, + z.string().max(MAX_SCRIM_POST_TEXT_LENGTH).nullable(), + ), + }) + .superRefine((post, ctx) => { + if ( + post.notFoundVisibility.at && + post.notFoundVisibility.forAssociation === post.baseVisibility + ) { + ctx.addIssue({ + path: ["notFoundVisibility"], + message: "Not found visibility must be different from base visibility", + code: z.ZodIssueCode.custom, + }); + } + + if (post.baseVisibility === "PUBLIC" && post.notFoundVisibility.at) { + ctx.addIssue({ + path: ["notFoundVisibility"], + message: + "Not found visibility can not be set if base visibility is public", + code: z.ZodIssueCode.custom, + }); + } + + if (post.notFoundVisibility.at && post.notFoundVisibility.at < post.at) { + ctx.addIssue({ + path: ["notFoundVisibility", "at"], + message: "Date can not be before the scrim date", + code: z.ZodIssueCode.custom, + }); + } + + if (post.notFoundVisibility.at && post.at < new Date()) { + ctx.addIssue({ + path: ["notFoundVisibility"], + message: "Can not be set if looking for scrim now", + code: z.ZodIssueCode.custom, + }); + } + }); diff --git a/app/features/scrims/scrims-types.ts b/app/features/scrims/scrims-types.ts new file mode 100644 index 000000000..132975dac --- /dev/null +++ b/app/features/scrims/scrims-types.ts @@ -0,0 +1,49 @@ +import type { CommonUser } from "../../utils/kysely.server"; +import type { AssociationVisibility } from "../associations/associations-types"; +import type { LUTI_DIVS } from "./scrims-constants"; + +export type LutiDiv = (typeof LUTI_DIVS)[number]; + +export interface ScrimPost { + id: number; + at: number; + visibility: AssociationVisibility | null; + text: string | null; + divs: { + /** Max div in the whole system is "X" */ + max: LutiDiv; + /** Min div in the whole system is "11" */ + min: LutiDiv; + } | null; + team: ScrimPostTeam | null; + users: Array; + chatCode: string | null; + requests: Array; + /** Is the post visible to the user because of their association membership? */ + isPrivate?: boolean; + permissions: { + MANAGE_REQUESTS: number[]; + DELETE_POST: number[]; + }; +} + +export interface ScrimPostRequest { + id: number; + isAccepted: boolean; + users: Array; + team: ScrimPostTeam | null; + permissions: { + CANCEL: number[]; + }; + createdAt: number; +} + +interface ScrimPostUser extends CommonUser { + isOwner: boolean; +} + +interface ScrimPostTeam { + name: string; + customUrl: string; + avatarUrl: string | null; +} diff --git a/app/features/scrims/scrims-utils.ts b/app/features/scrims/scrims-utils.ts new file mode 100644 index 000000000..2a00838b7 --- /dev/null +++ b/app/features/scrims/scrims-utils.ts @@ -0,0 +1,55 @@ +import * as R from "remeda"; +import type { LutiDiv, ScrimPost } from "./scrims-types"; + +export const getPostRequestCensor = + (userId: number) => + (post: ScrimPost): ScrimPost => { + return { + ...post, + requests: post.requests.filter((request) => { + const isOwnPost = post.users.some((user) => user.id === userId); + if (isOwnPost) { + return true; + } + + const isOwnRequest = request.users.some((user) => user.id === userId); + return isOwnRequest; + }), + }; + }; + +export function dividePosts(posts: Array, userId?: number) { + const grouped = R.groupBy(posts, (post) => { + if (post.users.some((user) => user.id === userId)) { + return "OWNED"; + } + + if ( + post.requests.some((request) => + request.users.some((user) => user.id === userId), + ) + ) { + return "REQUESTED"; + } + + return "NEUTRAL"; + }); + + return { + owned: grouped.OWNED ?? [], + requested: grouped.REQUESTED ?? [], + neutral: grouped.NEUTRAL ?? [], + }; +} + +export const parseLutiDiv = (div: number): LutiDiv => { + if (div === 0) return "X"; + + return String(div) as LutiDiv; +}; + +export const serializeLutiDiv = (div: LutiDiv): number => { + if (div === "X") return 0; + + return Number(div); +}; diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index f4986c8c5..9842b48b4 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -775,7 +775,7 @@ function BottomSection({ ["winners", "[]"], ...(!data.groupMemberOf ? [["adminReport", "on"] as const] : []), ]} - deleteButtonText={t("common:actions.cancel")} + submitButtonText={t("common:actions.cancel")} cancelButtonText={t("common:actions.nevermind")} fetcher={cancelFetcher} > diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index b49697e1f..b9446b1e1 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -611,7 +611,7 @@ function TrustedUsers() { ["_action", "REMOVE_TRUST"], ["userToRemoveTrustFromId", trustedUser.id], ]} - deleteButtonText="Remove" + submitButtonText="Remove" >