Scrims (#2211)
Some checks are pending
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

* Initial

* Progress

* Initial UI

* Can submit request

* Progress

* Show text if no scrims

* Can cancel request, tabs

* Delete post

* Popover if can't delete

* Request rows

* Progress

* Scrim page initial

* Fix migration order

* Progress

* Progress

* Works again

* Make it compile

* Make it compile again

* Work

* Progress

* Progress

* Progress

* Associations initial

* Association visibility work

* notFoundVisibility form fields initial

* Progress

* Association leave/join + reset invite code

* Progress

* Select test

* Merge branch 'rewrite' into scrims

* Remeda for groupBy

* Select with search

* Outline styling for select

* Select done?

* Fix prop names

* Paginated badges

* Less important

* Select no results

* Handle limiting select width

* UserSearch non-working

* Fix problem from merge

* Remove UserSearch for now

* Remove todo

* Flaggable

* Remove TODOs

* i18n start + styling

* Progress

* i18n done

* Add association e2e test

* E2E tests

* Done?

* Couple leftovers
This commit is contained in:
Kalle
2025-04-20 22:51:23 +03:00
committed by GitHub
parent c2b300aecb
commit b4cc185d1d
125 changed files with 5017 additions and 401 deletions

View File

@@ -6,6 +6,7 @@ import * as React from "react";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| "primary"
| "success"
| "outlined"
| "outlined-success"

View File

@@ -85,6 +85,17 @@ export function Catcher() {
)}
</Main>
);
case 403:
return (
<Main>
<h2>Error 403 Forbidden</h2>
<p className="text-sm text-lighter font-semi-bold">
Your account doesn't have the required permissions to perform this
action.
</p>
<GetHelp />
</Main>
);
case 404:
return (
<Main>

View File

@@ -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<any>;
}) {
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")}
</SubmitButton>
<Button onClick={closeDialog}>
<Button onClick={closeDialog} variant={cancelButtonVariant}>
{cancelButtonText ?? t("common:actions.cancel")}
</Button>
</div>

View File

@@ -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 (
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
<Tab.Group
selectedIndex={selectedIndex}
onChange={setSelectedIndex}
defaultIndex={defaultIndex}
>
<Tab.List
className={clsx("tab__buttons-container", {
"overflow-x-auto": scrolling,

View File

@@ -20,6 +20,7 @@ export const UserSearch = React.forwardRef<
userIdsToOmit?: Set<number>;
required?: boolean;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
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}
>
<Combobox.Input
ref={ref}

View File

@@ -0,0 +1,121 @@
.button {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: 2px solid var(--theme);
border-radius: var(--rounded-sm);
appearance: none;
background: var(--theme);
color: var(--button-text);
cursor: pointer;
font-size: var(--fonts-sm);
font-weight: var(--bold);
line-height: 1.2;
outline-offset: 2px;
padding-block: var(--s-1-5);
padding-inline: var(--s-2-5);
user-select: none;
}
.button[data-focus-visible] {
outline: 2px solid var(--theme);
}
.button[data-pressed] {
transform: translateY(1px);
}
.button[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
.outlined {
background-color: var(--theme-very-transparent);
color: var(--theme);
}
.outlinedSuccess {
border-color: var(--theme-success);
background-color: transparent;
color: var(--theme-success);
}
.small {
font-size: var(--fonts-xs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
.miniscule {
font-size: var(--fonts-xxs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
.big {
font-size: var(--fonts-md);
padding-block: var(--s-2-5);
padding-inline: var(--s-6);
}
.minimal {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme);
outline: initial;
}
.minimal[data-focus-visible] {
outline: 2px solid var(--theme);
}
.minimalSuccess {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme-success);
}
.success {
border-color: var(--theme-success);
background-color: var(--theme-success);
outline-color: var(--theme-success);
}
.destructive {
border-color: var(--theme-error);
background-color: transparent;
color: var(--theme-error);
outline-color: var(--theme-error);
}
.minimalDestructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme-error);
outline-color: var(--theme-error);
}
.buttonIcon {
width: 1.25rem;
margin-inline-end: var(--s-1-5);
}
.buttonIcon.lonely {
margin-inline-end: 0 !important;
}
.small > .buttonIcon {
width: 1rem;
margin-inline-end: var(--s-1);
}
.miniscule > .buttonIcon {
width: 0.857rem;
margin-inline-end: var(--s-1);
}

View File

@@ -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 (
<ReactAriaButton
{...rest}
className={clsx(
"react-aria-Button",
variant,
{
small: size === "small",
big: size === "big",
miniscule: size === "miniscule",
},
className,
)}
className={clsx(className, variantClassname, styles.button, {
[styles.small]: size === "small",
[styles.big]: size === "big",
[styles.miniscule]: size === "miniscule",
})}
>
{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}
</ReactAriaButton>
);
}
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);
}
}

View File

@@ -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);
}

View File

@@ -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<T extends object>
extends Omit<SelectProps<T>, "children"> {
label?: string;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
items?: Iterable<T>;
children: React.ReactNode | ((item: T) => React.ReactNode);
search?: {
placeholder?: string;
};
}
export function SendouSelect<T extends object>({
label,
description,
errorMessage,
children,
items,
search,
...props
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const { contains } = useFilter({ sensitivity: "base" });
return (
<Select {...props}>
{label ? <Label>{label}</Label> : null}
<Button className={styles.button}>
<SelectValue className={styles.selectValue} />
<span aria-hidden="true">
<ChevronUpDownIcon className={styles.icon} />
</span>
</Button>
{description && <Text slot="description">{description}</Text>}
<FieldError>{errorMessage}</FieldError>
<Popover className={styles.popover}>
<Autocomplete filter={contains}>
{search ? (
<SearchField
aria-label="Search"
autoFocus
className={styles.searchField}
>
<SearchIcon aria-hidden className={styles.smallIcon} />
<Input
placeholder={search.placeholder}
className={clsx("plain", styles.searchInput)}
/>
<Button className={styles.searchClearButton}>
<CrossIcon className={styles.smallIcon} />
</Button>
</SearchField>
) : null}
<Virtualizer layout={ListLayout} layoutOptions={{ rowHeight: 33 }}>
<ListBox
items={items}
className={styles.listBox}
renderEmptyState={() => (
<div className={styles.noResults}>{t("common:noResults")}</div>
)}
>
{children}
</ListBox>
</Virtualizer>
</Autocomplete>
</Popover>
</Select>
);
}
interface SendouSelectItemProps extends ListBoxItemProps {}
export function SendouSelectItem(props: SendouSelectItemProps) {
return (
<ListBoxItem
{...props}
className={({ isFocused, isSelected }) =>
clsx(styles.item, {
[styles.itemFocused]: isFocused,
[styles.itemSelected]: isSelected,
})
}
/>
);
}

View File

@@ -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<T extends FieldValues>({
label,
name,
bottomText,
}: { label: string; name: FieldPath<T>; bottomText?: string }) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id}>{label}</Label>
<input id={id} {...methods.register(name)} type="datetime-local" />
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -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<T extends z.ZodTypeAny>({
@@ -13,11 +14,15 @@ export function MyForm<T extends z.ZodTypeAny>({
defaultValues,
title,
children,
handleCancel,
cancelLink,
}: {
schema: T;
defaultValues?: DefaultValues<z.infer<T>>;
title?: string;
children: React.ReactNode;
handleCancel?: () => void;
cancelLink?: string;
}) {
const { t } = useTranslation(["common"]);
const fetcher = useFetcher<any>();
@@ -52,9 +57,29 @@ export function MyForm<T extends z.ZodTypeAny>({
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
{title ? <h1 className="text-lg">{title}</h1> : null}
{children}
<SubmitButton state={fetcher.state} className="mt-6">
{t("common:actions.submit")}
</SubmitButton>
<div className="stack horizontal lg justify-between mt-6 w-full">
<SubmitButton state={fetcher.state}>
{t("common:actions.submit")}
</SubmitButton>
{handleCancel ? (
<Button
variant="minimal-destructive"
onClick={handleCancel}
size="tiny"
>
{t("common:actions.cancel")}
</Button>
) : null}
{cancelLink ? (
<LinkButton
variant="minimal-destructive"
to={cancelLink}
size="tiny"
>
{t("common:actions.cancel")}
</LinkButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
);

View File

@@ -0,0 +1,17 @@
export function ArrowDownOnSquareIcon({
className,
}: {
className?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Arrow Down On Square Icon</title>
<path d="M12 1.5a.75.75 0 0 1 .75.75V7.5h-1.5V2.25A.75.75 0 0 1 12 1.5ZM11.25 7.5v5.69l-1.72-1.72a.75.75 0 0 0-1.06 1.06l3 3a.75.75 0 0 0 1.06 0l3-3a.75.75 0 1 0-1.06-1.06l-1.72 1.72V7.5h3.75a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h3.75Z" />
</svg>
);
}

View File

@@ -0,0 +1,17 @@
export function ArrowUpOnSquareIcon({
className,
}: {
className?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Arrow Up On Square Icon</title>
<path d="M11.47 1.72a.75.75 0 0 1 1.06 0l3 3a.75.75 0 0 1-1.06 1.06l-1.72-1.72V7.5h-1.5V4.06L9.53 5.78a.75.75 0 0 1-1.06-1.06l3-3ZM11.25 7.5V15a.75.75 0 0 0 1.5 0V7.5h3.75a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3h-9a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3h3.75Z" />
</svg>
);
}

View File

@@ -0,0 +1,18 @@
export function ChevronUpDownIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
className={className}
>
<title>Chevron Up Down Icon</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
</svg>
);
}

View File

@@ -0,0 +1,13 @@
export function MegaphoneIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Megaphone Icon</title>
<path d="M16.881 4.345A23.112 23.112 0 0 1 8.25 6H7.5a5.25 5.25 0 0 0-.88 10.427 21.593 21.593 0 0 0 1.378 3.94c.464 1.004 1.674 1.32 2.582.796l.657-.379c.88-.508 1.165-1.593.772-2.468a17.116 17.116 0 0 1-.628-1.607c1.918.258 3.76.75 5.5 1.446A21.727 21.727 0 0 0 18 11.25c0-2.414-.393-4.735-1.119-6.905ZM18.26 3.74a23.22 23.22 0 0 1 1.24 7.51 23.22 23.22 0 0 1-1.41 7.992.75.75 0 1 0 1.409.516 24.555 24.555 0 0 0 1.415-6.43 2.992 2.992 0 0 0 .836-2.078c0-.807-.319-1.54-.836-2.078a24.65 24.65 0 0 0-1.415-6.43.75.75 0 1 0-1.409.516c.059.16.116.321.17.483Z" />
</svg>
);
}

View File

@@ -0,0 +1,17 @@
export function SpeechBubbleFilledIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Speech Bubble Filled Icon</title>
<path
fillRule="evenodd"
d="M4.804 21.644A6.707 6.707 0 0 0 6 21.75a6.721 6.721 0 0 0 3.583-1.029c.774.182 1.584.279 2.417.279 5.322 0 9.75-3.97 9.75-9 0-5.03-4.428-9-9.75-9s-9.75 3.97-9.75 9c0 2.409 1.025 4.587 2.674 6.192.232.226.277.428.254.543a3.73 3.73 0 0 1-.814 1.686.75.75 0 0 0 .44 1.223ZM8.25 10.875a1.125 1.125 0 1 0 0 2.25 1.125 1.125 0 0 0 0-2.25ZM10.875 12a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Zm4.875-1.125a1.125 1.125 0 1 0 0 2.25 1.125 1.125 0 0 0 0-2.25Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -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 <Menu items={items} button={FilterMenuButton} opensLeft />;
}

View File

@@ -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",

View File

@@ -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[] = [
{

View File

@@ -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<number>;
/** 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<number>;
updatedAt: Generated<number>;
}
export interface ScrimPostUser {
scrimPostId: number;
userId: number;
/** User is the author of the post */
isOwner: number;
}
export interface ScrimPostRequest {
id: GeneratedAlways<number>;
scrimPostId: number;
teamId: number | null;
isAccepted: Generated<number>;
createdAt: GeneratedAlways<number>;
}
export interface ScrimPostRequestUser {
scrimPostRequestId: number;
userId: number;
/** User made the request */
isOwner: number;
}
export interface Association {
id: GeneratedAlways<number>;
name: string;
inviteCode: string;
createdAt: GeneratedAlways<number>;
}
export interface AssociationMember {
userId: number;
associationId: number;
role: "MEMBER" | "ADMIN";
}
export interface Notification {
id: GeneratedAlways<number>;
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;

View File

@@ -234,7 +234,7 @@ function ImagePreview({
["id", art.id],
["_action", "UNLINK_ART"],
]}
deleteButtonText={t("common:actions.remove")}
submitButtonText={t("common:actions.remove")}
>
<Button icon={<UnlinkIcon />} variant="destructive" size="tiny" />
</FormWithConfirm>

View File

@@ -0,0 +1,186 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { nanoid } from "nanoid";
import { INVITE_CODE_LENGTH } from "~/constants";
import { db } from "~/db/sql";
import type { TablesInsertable, TablesUpdatable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
interface FindOptions {
withMembers: boolean;
}
export async function findById(
associationId: number,
options: FindOptions = { withMembers: false },
) {
const result = await findBy({ type: "association", associationId }, options);
return result.at(0) ?? null;
}
export async function findByMemberUserId(
userId: number,
options: FindOptions = { withMembers: false },
) {
return {
actual: await findBy({ type: "user", userId }, options),
virtual: await virtualAssociationsByUserId(userId),
};
}
export async function findByInviteCode(
inviteCode: string,
options: FindOptions = { withMembers: false },
) {
const associations = await findBy(
{ type: "inviteCode", inviteCode },
options,
);
return associations.at(0);
}
const baseFindQuery = (options: FindOptions) =>
db
.selectFrom("AssociationMember")
.innerJoin(
"Association",
"Association.id",
"AssociationMember.associationId",
)
.select(["Association.id", "Association.name"])
.$if(options.withMembers, (qb) =>
qb.select((eb) =>
jsonArrayFrom(
eb
.selectFrom("AssociationMember")
.innerJoin("User", "User.id", "AssociationMember.userId")
.whereRef("AssociationMember.associationId", "=", "Association.id")
.select([...COMMON_USER_FIELDS, "AssociationMember.role"]),
).as("members"),
),
);
async function findBy(
args:
| { type: "user"; userId: number }
| { type: "association"; associationId: number }
| { type: "inviteCode"; inviteCode: string },
options: FindOptions,
) {
const associations =
args.type === "user"
? await baseFindQuery(options)
.where("AssociationMember.userId", "=", args.userId)
.execute()
: args.type === "inviteCode"
? await baseFindQuery(options)
.where("Association.inviteCode", "=", args.inviteCode)
.execute()
: await baseFindQuery(options)
.where("Association.id", "=", args.associationId)
.execute();
return associations.map((a) => ({
...a,
permissions: {
MANAGE: (a.members ?? [])
.filter((member) => member.role === "ADMIN")
.map((user) => user.id),
},
}));
}
async function virtualAssociationsByUserId(
userId: number,
): Promise<Array<AssociationVirtualIdentifier>> {
const { plusTier } =
(await db
.selectFrom("PlusTier")
.select(["PlusTier.tier as plusTier"])
.where("userId", "=", userId)
.executeTakeFirst()) ?? {};
if (!plusTier) return [];
if (plusTier === 1) return ["+1", "+2", "+3"] as const;
if (plusTier === 2) return ["+2", "+3"] as const;
if (plusTier === 3) return ["+3"] as const;
logger.error("Invalid plusTier", { plusTier });
return [];
}
type InsertArgs = Omit<TablesInsertable["Association"], "inviteCode"> & {
userId: number;
};
export async function findInviteCodeById(associationId: number) {
const row = await db
.selectFrom("Association")
.select(["Association.inviteCode"])
.where("id", "=", associationId)
.executeTakeFirstOrThrow();
return row.inviteCode;
}
export function insert({ userId, ...associationArgs }: InsertArgs) {
return db.transaction().execute(async (trx) => {
const association = await trx
.insertInto("Association")
.values({ ...associationArgs, inviteCode: nanoid(INVITE_CODE_LENGTH) })
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("AssociationMember")
.values({ userId, associationId: association.id, role: "ADMIN" })
.execute();
});
}
export function update(
associationId: number,
args: Partial<TablesUpdatable["Association"]>,
) {
return db
.updateTable("Association")
.set(args)
.where("id", "=", associationId)
.execute();
}
export function refreshInviteCode(associationId: number) {
return db
.updateTable("Association")
.set({ inviteCode: nanoid(INVITE_CODE_LENGTH) })
.where("id", "=", associationId)
.execute();
}
export function addMember({
associationId,
userId,
}: { associationId: number; userId: number }) {
return db
.insertInto("AssociationMember")
.values({ associationId, userId, role: "MEMBER" })
.execute();
}
export function removeMember({
associationId,
userId,
}: { associationId: number; userId: number }) {
return db
.deleteFrom("AssociationMember")
.where("associationId", "=", associationId)
.where("userId", "=", userId)
.execute();
}
export function del(associationId: number) {
return db.deleteFrom("Association").where("id", "=", associationId).execute();
}

View File

@@ -0,0 +1,37 @@
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { requireUser } from "~/features/auth/core/user.server";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
import { associationsPage } from "~/utils/urls";
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
import * as AssociationRepository from "../AssociationRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: createNewAssociationSchema,
});
const associationCount = (
await AssociationRepository.findByMemberUserId(user.id)
).actual;
const maxAssociationCount = isAtLeastFiveDollarTierPatreon(user)
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
if (associationCount.length >= maxAssociationCount) {
return actionError<typeof createNewAssociationSchema>({
msg: `Regular users can only be a member of ${maxAssociationCount} associations (supporters ${ASSOCIATION.MAX_COUNT_SUPPORTER})`,
field: "name",
});
}
await AssociationRepository.insert({
name: data.name,
userId: user.id,
});
return redirect(associationsPage());
};

View File

@@ -0,0 +1,131 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { associationsPageActionSchema } from "~/features/associations/associations-schemas";
import { requireUser } from "~/features/auth/core/user.server";
import { requirePermission } from "~/modules/permissions/requirePermission.server";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
import * as AssociationRepository from "../AssociationRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: associationsPageActionSchema,
});
switch (data._action) {
case "REMOVE_MEMBER": {
await validateHasManagePermissions({
user,
associationId: data.associationId,
});
errorToastIfFalsy(
data.userId !== user.id,
"Cannot remove yourself from the association",
);
await AssociationRepository.removeMember({
userId: data.userId,
associationId: data.associationId,
});
break;
}
case "DELETE_ASSOCIATION": {
await validateHasManagePermissions({
user,
associationId: data.associationId,
});
await AssociationRepository.del(data.associationId);
break;
}
case "REFRESH_INVITE_CODE": {
await validateHasManagePermissions({
user,
associationId: data.associationId,
});
await AssociationRepository.refreshInviteCode(data.associationId);
return successToast("Invite code reset");
}
case "JOIN_ASSOCIATION": {
const associationToJoin = badRequestIfFalsy(
await AssociationRepository.findByInviteCode(data.inviteCode, {
withMembers: true,
}),
);
errorToastIfFalsy(
associationToJoin.members?.every((member) => member.id !== user.id),
"You are already a member of this association",
);
errorToastIfFalsy(
associationToJoin.members!.length <
ASSOCIATION.MAX_ASSOCIATION_MEMBER_COUNT,
"Association is full",
);
const maxAssociationCount = isAtLeastFiveDollarTierPatreon(user)
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
errorToastIfFalsy(
(await AssociationRepository.findByMemberUserId(user.id)).actual
.length < maxAssociationCount,
`Regular users can only be a member of ${maxAssociationCount} associations (supporters ${ASSOCIATION.MAX_COUNT_SUPPORTER})`,
);
await AssociationRepository.addMember({
userId: user.id,
associationId: associationToJoin.id,
});
break;
}
case "LEAVE_ASSOCIATION": {
const association = badRequestIfFalsy(
await AssociationRepository.findById(data.associationId, {
withMembers: true,
}),
);
errorToastIfFalsy(
!association.permissions.MANAGE.includes(user.id),
"You cannot leave an association you manage",
);
await AssociationRepository.removeMember({
userId: user.id,
associationId: data.associationId,
});
return successToast("Left association");
}
default: {
assertUnreachable(data);
}
}
return null;
};
async function validateHasManagePermissions({
user,
associationId,
}: { user: { id: number }; associationId: number }) {
const association = badRequestIfFalsy(
await AssociationRepository.findById(associationId, { withMembers: true }),
);
requirePermission(association, "MANAGE", user);
}

View File

@@ -0,0 +1,12 @@
export const ASSOCIATION = {
VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3"] as const,
MAX_COUNT_REGULAR_USER: 3,
MAX_COUNT_SUPPORTER: 6,
MAX_ASSOCIATION_MEMBER_COUNT: 300,
};
export type AssociationVirtualIdentifier =
(typeof ASSOCIATION)["VIRTUAL_IDENTIFIERS"][number];
/** If number, an actual association id and if string then a virtual identifier */
export type AssociationIdentifier = number | AssociationVirtualIdentifier;

View File

@@ -0,0 +1,51 @@
import { z } from "zod";
import { _action, id, inviteCode, safeStringSchema } from "~/utils/zod";
import { ASSOCIATION } from "./associations-constants";
export const createNewAssociationSchema = z.object({
name: safeStringSchema({ max: 100 }),
});
const removeMemberSchema = z.object({
_action: _action("REMOVE_MEMBER"),
associationId: id,
userId: id,
});
const deleteAssociationSchema = z.object({
_action: _action("DELETE_ASSOCIATION"),
associationId: id,
});
const refreshInviteCodeSchema = z.object({
_action: _action("REFRESH_INVITE_CODE"),
associationId: id,
});
const joinAssociationSchema = z.object({
_action: _action("JOIN_ASSOCIATION"),
inviteCode,
});
const leaveAssociationSchema = z.object({
_action: _action("LEAVE_ASSOCIATION"),
associationId: id,
});
export const associationsPageActionSchema = z.union([
removeMemberSchema,
deleteAssociationSchema,
refreshInviteCodeSchema,
joinAssociationSchema,
leaveAssociationSchema,
]);
const virtualAssociationIdentifierSchema = z.enum(
ASSOCIATION.VIRTUAL_IDENTIFIERS,
);
export const associationIdentifierSchema = z.union([
virtualAssociationIdentifierSchema,
id,
z.literal("PUBLIC"), // null in DB
]);

View File

@@ -0,0 +1,12 @@
import type { AssociationIdentifier } from "./associations-constants";
export interface AssociationVisibility {
/** Which association should see it */
forAssociation: AssociationIdentifier;
notFoundInstructions?: Array<{
/** When to expand the visibility */
at: number;
/** To which association expand the visibility to? null indicates public */
forAssociation: AssociationIdentifier | null;
}>;
}

View File

@@ -0,0 +1,121 @@
import { add } from "date-fns";
import { describe, expect, it } from "vitest";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import * as Association from "./Association";
describe("isVisible", () => {
it("should return true if visibility is null", () => {
const args: Association.IsVisibleArgs = {
visibility: null,
time: new Date(),
associations: null,
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return false if not member of the association", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: 1 },
time: new Date(),
associations: null,
};
expect(Association.isVisible(args)).toBe(false);
});
it("should return true if member of the association", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: 1 },
time: new Date(),
associations: {
actual: [{ id: 1 }],
virtual: [],
},
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return true if member of the virtual association", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "+1" },
time: new Date(),
associations: {
actual: [],
virtual: ["+1"],
},
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return false if not yet visible", () => {
const visibleAt = add(new Date(), { days: 1 });
const args: Association.IsVisibleArgs = {
visibility: {
forAssociation: "+1",
notFoundInstructions: [
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: 1 },
],
},
time: new Date(),
associations: {
actual: [{ id: 1 }],
virtual: [],
},
};
expect(Association.isVisible(args)).toBe(false);
});
it("should return true if has become visible", () => {
const visibleAt = add(new Date(), { days: 1 });
const args: Association.IsVisibleArgs = {
visibility: {
forAssociation: "+1",
notFoundInstructions: [
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: 1 },
],
},
time: add(new Date(), { days: 2 }),
associations: {
actual: [{ id: 1 }],
virtual: [],
},
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return true if has become public", () => {
const visibleAt = add(new Date(), { days: 1 });
const args: Association.IsVisibleArgs = {
visibility: {
forAssociation: "+1",
notFoundInstructions: [
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: null },
],
},
time: add(new Date(), { days: 2 }),
associations: {
actual: [],
virtual: [],
},
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return true if has become public (no associations)", () => {
const visibleAt = add(new Date(), { days: 1 });
const args: Association.IsVisibleArgs = {
visibility: {
forAssociation: "+1",
notFoundInstructions: [
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: null },
],
},
time: add(new Date(), { days: 2 }),
associations: null,
};
expect(Association.isVisible(args)).toBe(true);
});
});

View File

@@ -0,0 +1,49 @@
import { dateToDatabaseTimestamp } from "~/utils/dates";
import type { AssociationIdentifier } from "../associations-constants";
import type { AssociationVisibility } from "../associations-types";
export interface IsVisibleArgs {
visibility: AssociationVisibility | null;
time: Date;
associations: {
virtual: Array<string>;
actual: Array<{ id: number }>;
} | null;
}
export function isVisible(args: IsVisibleArgs) {
if (!args.visibility) return true;
const currentVisibility: Array<AssociationIdentifier | null> = [
args.visibility.forAssociation,
];
const dbTime = dateToDatabaseTimestamp(args.time);
for (const visibility of args.visibility.notFoundInstructions ?? []) {
if (dbTime > visibility.at) {
currentVisibility.push(visibility.forAssociation);
}
}
const isPublic = currentVisibility.includes(null);
if (isPublic) return true;
return (
args.associations?.actual.some((association) =>
currentVisibility.includes(association.id),
) ||
args.associations?.virtual.some((association) =>
currentVisibility.includes(association as any),
) ||
false
);
}
export function isPublic(args: Omit<IsVisibleArgs, "associations">) {
return isVisible({
associations: null,
time: args.time,
visibility: args.visibility,
});
}

View File

@@ -0,0 +1,61 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import { parseSafeSearchParams } from "~/utils/remix.server";
import { inviteCodeObject } from "~/utils/zod";
import * as AssociationRepository from "../AssociationRepository.server";
export type AssociationsLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUserId(request);
const associations = (
await AssociationRepository.findByMemberUserId(user.id, {
withMembers: true,
})
).actual;
const associationsWithInviteCodes = await Promise.all(
associations.map(async (association) => ({
...association,
inviteCode: association.permissions.MANAGE.includes(user.id)
? await AssociationRepository.findInviteCodeById(association.id)
: undefined,
})),
);
return {
associations: associationsWithInviteCodes,
toJoin: await associationToJoin(request, user.id),
};
};
async function associationToJoin(
request: LoaderFunctionArgs["request"],
userId: number,
) {
const searchParams = parseSafeSearchParams({
request,
schema: inviteCodeObject,
});
if (!searchParams.success) return null;
const associationToJoin = await AssociationRepository.findByInviteCode(
searchParams.data.inviteCode,
{
withMembers: true,
},
);
if (!associationToJoin) return null;
if (associationToJoin.members!.some((member) => member.id === userId)) {
return null;
}
return {
association: associationToJoin,
inviteCode: searchParams.data.inviteCode,
};
}

View File

@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Dialog } from "~/components/Dialog";
import { MyForm } from "~/components/form/MyForm";
import { TextFormField } from "~/components/form/TextFormField";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage } from "~/utils/urls";
import { action } from "../actions/associations.new.server";
export { action };
type FormFields = z.infer<typeof createNewAssociationSchema>;
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
export default function AssociationsNewPage() {
const { t } = useTranslation(["scrims"]);
return (
<Dialog isOpen>
<MyForm
title={t("scrims:associations.forms.title")}
schema={createNewAssociationSchema}
defaultValues={{
name: "",
}}
cancelLink={associationsPage()}
>
<TextFormField<FormFields>
label={t("scrims:associations.forms.name.title")}
name="name"
/>
</MyForm>
</Dialog>
);
}

View File

@@ -0,0 +1,255 @@
import { Link, Outlet, useFetcher, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useCopyToClipboard } from "react-use";
import { Avatar } from "~/components/Avatar";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SendouButton } from "~/components/elements/Button";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { ClipboardIcon } from "~/components/icons/Clipboard";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core/user";
import { useHasPermission } from "~/modules/permissions/useHasPermission";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage, userPage } from "~/utils/urls";
import { action } from "~/features/associations/actions/associations.server";
import {
type AssociationsLoaderData,
loader,
} from "~/features/associations/loaders/associations.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
export default function AssociationsPage() {
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<Outlet />
<Header />
<JoinForm />
{data.associations.map((association) => (
<Association key={association.id} association={association} />
))}
</Main>
);
}
function Header() {
const { t } = useTranslation(["scrims"]);
return (
<div>
<h1 className="text-xl">{t("scrims:associations.title")}</h1>
<div className="text-sm text-lighter">
{t("scrims:associations.explanation")}
</div>
</div>
);
}
function JoinForm() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const { t } = useTranslation(["common", "scrims"]);
if (!data.toJoin) return null;
return (
<fetcher.Form method="post" className="stack horizontal md items-center">
<input type="hidden" name="inviteCode" value={data.toJoin.inviteCode} />
<Label spaced={false}>
{t("scrims:associations.join.title", {
name: data.toJoin.association.name,
})}
</Label>
<SubmitButton
size="tiny"
_action="JOIN_ASSOCIATION"
state={fetcher.state}
>
{t("common:actions.join")}
</SubmitButton>
</fetcher.Form>
);
}
function Association({
association,
}: { association: AssociationsLoaderData["associations"][number] }) {
const { t } = useTranslation(["common", "scrims"]);
const user = useUser();
const canManage = useHasPermission(association, "MANAGE");
return (
<section>
<div className="stack horizontal sm">
<h2 className="text-lg"> {association.name}</h2>
{canManage ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.delete.title", {
name: association.name,
})}
fields={[
["associationId", association.id],
["_action", "DELETE_ASSOCIATION"],
]}
>
<SendouButton
icon={<TrashIcon className="build__icon" />}
className="build__small-text"
variant="minimal-destructive"
type="submit"
data-testid="delete-association"
/>
</FormWithConfirm>
) : null}
</div>
<div className="text-sm text-lighter">
{t("scrims:associations.admin", {
username: association.members?.find((m) => m.role === "ADMIN")
?.username,
})}
</div>
{!canManage ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.leave.title", {
name: association.name,
})}
fields={[
["_action", "LEAVE_ASSOCIATION"],
["associationId", association.id],
]}
submitButtonText={t("scrims:associations.leave.action")}
>
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
className="my-2"
data-testid="leave-team-button"
>
{t("scrims:associations.leave.action")}
</SendouButton>
</FormWithConfirm>
) : null}
<div className="stack sm mt-4">
{association.members?.map((member) => (
<AssociationMember
key={member.id}
member={member}
associationId={association.id}
showControls={canManage && member.id !== user?.id}
/>
))}
</div>
{association.inviteCode ? (
<AssociationInviteCodeActions
associationId={association.id}
inviteCode={association.inviteCode}
/>
) : null}
</section>
);
}
function AssociationInviteCodeActions({
associationId,
inviteCode,
}: { associationId: number; inviteCode: string }) {
const { t } = useTranslation(["common", "scrims"]);
const [state, copyToClipboard] = useCopyToClipboard();
const [copySuccess, setCopySuccess] = React.useState(false);
const fetcher = useFetcher();
React.useEffect(() => {
if (!state.value) return;
setCopySuccess(true);
const timeout = setTimeout(() => setCopySuccess(false), 2000);
return () => clearTimeout(timeout);
}, [state]);
const inviteLink = `https://sendou.ink${associationsPage(inviteCode)}`;
return (
<div className="mt-6">
<label htmlFor="invite">{t("scrims:associations.shareLink.title")}</label>
<div className="stack horizontal sm items-center">
<input type="text" value={inviteLink} readOnly id="invite" />
<SendouButton
variant={copySuccess ? "outlined-success" : "outlined"}
onPress={() => copyToClipboard(inviteLink)}
icon={copySuccess ? <CheckmarkIcon /> : <ClipboardIcon />}
aria-label="Copy to clipboard"
/>
</div>
<fetcher.Form method="post">
<input type="hidden" name="associationId" value={associationId} />
<SubmitButton
variant="minimal-destructive"
size="tiny"
className="mt-4"
_action="REFRESH_INVITE_CODE"
state={fetcher.state}
>
{t("scrims:associations.shareLink.reset")}
</SubmitButton>
</fetcher.Form>
</div>
);
}
function AssociationMember({
member,
associationId,
showControls,
}: {
member: NonNullable<
AssociationsLoaderData["associations"][number]["members"]
>[number];
associationId: number;
showControls?: boolean;
}) {
const { t } = useTranslation(["common", "scrims"]);
return (
<div className="stack horizontal sm">
<Link
to={userPage(member)}
className="text-main-forced stack horizontal sm"
>
<Avatar size="xxs" user={member} />
{member.username}
</Link>
{showControls ? (
<FormWithConfirm
dialogHeading={t("scrims:associations.removeMember.title", {
username: member.username,
})}
submitButtonText={t("common:actions.remove")}
fields={[
["userId", member.id],
["associationId", associationId],
["_action", "REMOVE_MEMBER"],
]}
>
<SendouButton
icon={<TrashIcon className="build__icon" />}
className="build__small-text"
variant="minimal-destructive"
type="submit"
/>
</FormWithConfirm>
) : null}
</div>
);
}

View File

@@ -0,0 +1,73 @@
.badges {
display: flex;
min-width: 20rem;
max-width: 20rem;
min-height: 12rem;
align-items: center;
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-badge);
margin-inline: auto;
}
.smallBadges {
display: grid;
place-items: center;
grid-template-columns: repeat(3, 1fr);
margin: 0 auto;
cursor: pointer;
gap: var(--s-3);
}
.badgeExplanation {
color: var(--text-lighter);
font-size: var(--fonts-xs);
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
margin-block-end: var(--s-1);
}
.smallBadgeContainer {
position: relative;
}
.smallBadgeCount {
position: absolute;
top: 0;
right: 0;
margin-top: -8px;
margin-right: auto;
margin-left: auto;
color: var(--theme-vibrant);
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
}
.pagination {
display: flex;
flex-wrap: wrap;
gap: var(--s-2-5);
justify-content: center;
align-items: center;
max-width: 20rem;
margin: 0 auto;
margin-block-start: var(--s-2);
}
.paginationButton {
background-color: var(--bg-darker);
border-radius: 100%;
padding: var(--s-1);
height: 24px;
width: 24px;
border: 2px solid var(--border);
font-size: var(--fonts-xs);
color: var(--text-lighter);
}
.paginationButtonActive {
color: var(--theme);
background-color: var(--bg-lightest);
}

View File

@@ -3,10 +3,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "~/components/Badge";
import { Button } from "~/components/Button";
import { SendouButton } from "~/components/elements/Button";
import { TrashIcon } from "~/components/icons/Trash";
import type { Tables } from "~/db/tables";
import { usePagination } from "~/hooks/usePagination";
import type { Unpacked } from "~/utils/types";
import { badgeExplanationText } from "../badges-utils";
import styles from "./BadgeDisplay.module.css";
interface BadgeDisplayProps {
badges: Array<Omit<Tables["Badge"], "authorId"> & { count?: number }>;
@@ -21,6 +24,19 @@ export function BadgeDisplay({
const [badges, setBadges] = React.useState(_badges);
const [bigBadge, ...smallBadges] = badges;
const {
itemsToDisplay,
everythingVisible,
currentPage,
pagesCount,
setPage,
} = usePagination({
items: smallBadges,
pageSize: 9,
scrollToTop: false,
});
if (!bigBadge) return null;
const setBadgeFirst = (badge: Unpacked<BadgeDisplayProps["badges"]>) => {
@@ -36,36 +52,7 @@ export function BadgeDisplay({
return (
<div>
<div
className={clsx("badge-display__badges", {
"justify-center": smallBadges.length === 0,
})}
>
<Badge badge={bigBadge} size={125} isAnimated />
{smallBadges.length > 0 ? (
<div className="badge-display__small-badges">
{smallBadges.map((badge) => (
<div
key={badge.id}
className="badge-display__small-badge-container"
>
<Badge
badge={badge}
onClick={() => setBadgeFirst(badge)}
size={48}
isAnimated
/>
{badge.count && badge.count > 1 ? (
<div className="badge-display__small-badge-count">
×{badge.count}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
<div className="badge-display__badge-explanation">
<div className={styles.badgeExplanation}>
{badgeExplanationText(t, bigBadge)}
{onBadgeRemove ? (
<Button
@@ -75,6 +62,67 @@ export function BadgeDisplay({
/>
) : null}
</div>
<div
className={clsx(styles.badges, {
"justify-center": smallBadges.length === 0,
})}
>
<Badge badge={bigBadge} size={125} isAnimated />
{smallBadges.length > 0 ? (
<div className={styles.smallBadges}>
{itemsToDisplay.map((badge) => (
<div key={badge.id} className={styles.smallBadgeContainer}>
<Badge
badge={badge}
onClick={() => setBadgeFirst(badge)}
size={48}
isAnimated
/>
{badge.count && badge.count > 1 ? (
<div className={styles.smallBadgeCount}>×{badge.count}</div>
) : null}
</div>
))}
</div>
) : null}
</div>
{!everythingVisible ? (
<BadgePagination
pagesCount={pagesCount}
currentPage={currentPage}
setPage={setPage}
/>
) : null}
</div>
);
}
interface BadgePaginationProps {
pagesCount: number;
currentPage: number;
setPage: (page: number) => void;
}
function BadgePagination({
pagesCount,
currentPage,
setPage,
}: BadgePaginationProps) {
return (
<div className={styles.pagination}>
{Array.from({ length: pagesCount }, (_, i) => (
<SendouButton
key={i}
variant="minimal"
aria-label={`Badges page ${i + 1}`}
onPress={() => setPage(i + 1)}
className={clsx(styles.paginationButton, {
[styles.paginationButtonActive]: currentPage === i + 1,
})}
>
{i + 1}
</SendouButton>
))}
</div>
);
}

View File

@@ -18,7 +18,7 @@ import { useChatAutoScroll } from "../chat-hooks";
import type { ChatMessage } from "../chat-types";
import { messageTypeToSound, soundEnabled, soundVolume } from "../chat-utils";
type ChatUser = Pick<
export type ChatUser = Pick<
Tables["User"],
"username" | "discordId" | "discordAvatar"
> & {

View File

@@ -42,7 +42,7 @@ function ImageValidator() {
{i + 1}){" "}
<FormWithConfirm
dialogHeading={`Reject image submitted by ${image.username}?`}
deleteButtonText="Reject"
submitButtonText="Reject"
fields={[
["imageId", image.id],
["_action", "REJECT"],

View File

@@ -52,11 +52,26 @@ const PERKS = [
name: "prioritySupport",
extraInfo: true,
},
{
tier: 2,
name: "tournamentsBeta",
extraInfo: false,
},
{
tier: 2,
name: "previewQ",
extraInfo: false,
},
{
tier: 2,
name: "userShortLink",
extraInfo: true,
},
{
tier: 2,
name: "autoValidatePictures",
extraInfo: true,
},
{
tier: 2,
name: "customizedColorsUser",
@@ -87,16 +102,6 @@ const PERKS = [
name: "seePlusPercentage",
extraInfo: true,
},
{
tier: 2,
name: "autoValidatePictures",
extraInfo: true,
},
{
tier: 2,
name: "previewQ",
extraInfo: false,
},
{
tier: 2,
name: "joinFive",
@@ -104,9 +109,14 @@ const PERKS = [
},
{
tier: 2,
name: "tournamentsBeta",
name: "joinMoreAssociations",
extraInfo: false,
},
{
tier: 2,
name: "useBotToLogIn",
extraInfo: true,
},
] as const;
export default function SupportPage() {

View File

@@ -23,9 +23,10 @@ export function insert(
await trx
.insertInto("NotificationUser")
.values(
users.map(({ userId }) => ({
users.map(({ userId, seen }) => ({
userId,
notificationId: inserted.id,
seen,
})),
)
.execute();

View File

@@ -53,7 +53,9 @@ export type Notification =
"TAGGED_TO_ART",
{ adderUsername: string; adderDiscordId: string; artId: number }
>
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>;
| NotificationItem<"SEASON_STARTED", { seasonNth: number }>
| NotificationItem<"SCRIM_NEW_REQUEST", { fromUsername: string }>
| NotificationItem<"SCRIM_SCHEDULED", { id: number; timeString: string }>;
type NotificationItem<
T extends string,

View File

@@ -4,6 +4,8 @@ import {
SENDOUQ_PAGE,
badgePage,
plusSuggestionPage,
scrimPage,
scrimsPage,
sendouQMatchPage,
tournamentBracketsPage,
tournamentRegisterPage,
@@ -30,6 +32,9 @@ export const notificationNavIcon = (type: Notification["type"]) => {
case "TO_BRACKET_STARTED":
case "TO_CHECK_IN_OPENED":
return "medal";
case "SCRIM_NEW_REQUEST":
case "SCRIM_SCHEDULED":
return "scrims";
default:
assertUnreachable(type);
}
@@ -68,6 +73,12 @@ export const notificationLink = (notification: Notification) => {
});
case "TO_CHECK_IN_OPENED":
return tournamentRegisterPage(notification.meta.tournamentId);
case "SCRIM_NEW_REQUEST": {
return scrimsPage();
}
case "SCRIM_SCHEDULED": {
return scrimPage(notification.meta.id);
}
default:
assertUnreachable(notification);
}

View File

@@ -0,0 +1,271 @@
import { sub } from "date-fns";
import type { Insertable } from "kysely";
import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
import { nanoid } from "nanoid";
import type { Tables, TablesInsertable } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { INVITE_CODE_LENGTH } from "../../constants";
import { db } from "../../db/sql";
import invariant from "../../utils/invariant";
import type { Unwrapped } from "../../utils/types";
import type { AssociationVisibility } from "../associations/associations-types";
import * as Scrim from "./core/Scrim";
import type { ScrimPost } from "./scrims-types";
import { getPostRequestCensor, parseLutiDiv } from "./scrims-utils";
type InsertArgs = Pick<
TablesInsertable["ScrimPost"],
"at" | "maxDiv" | "minDiv" | "teamId" | "text"
> & {
/** users related to the post other than the author */
users: Array<Pick<Insertable<Tables["ScrimPostUser"]>, "userId" | "isOwner">>;
visibility: AssociationVisibility | null;
};
export function insert(args: InsertArgs) {
if (args.users.length === 0) {
throw new Error("At least one user must be provided");
}
return db.transaction().execute(async (trx) => {
const newPost = await trx
.insertInto("ScrimPost")
.values({
at: args.at,
maxDiv: args.maxDiv,
minDiv: args.minDiv,
teamId: args.teamId,
text: args.text,
visibility: args.visibility ? JSON.stringify(args.visibility) : null,
chatCode: nanoid(INVITE_CODE_LENGTH),
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("ScrimPostUser")
.values(args.users.map((user) => ({ ...user, scrimPostId: newPost.id })))
.execute();
return newPost.id;
});
}
type InsertRequestArgs = Pick<
Insertable<Tables["ScrimPostRequest"]>,
"scrimPostId" | "teamId"
> & {
users: Array<
Pick<Insertable<Tables["ScrimPostRequestUser"]>, "userId" | "isOwner">
>;
};
export function insertRequest(args: InsertRequestArgs) {
invariant(args.users.length > 0, "At least one user must be provided");
return db.transaction().execute(async (trx) => {
const newRequest = await trx
.insertInto("ScrimPostRequest")
.values({
scrimPostId: args.scrimPostId,
teamId: args.teamId,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx
.insertInto("ScrimPostRequestUser")
.values(
args.users.map((user) => ({
isOwner: user.isOwner,
userId: user.userId,
scrimPostRequestId: newRequest.id,
})),
)
.execute();
});
}
export function del(scrimPostId: number) {
return db.deleteFrom("ScrimPost").where("id", "=", scrimPostId).execute();
}
const baseFindQuery = db
.selectFrom("ScrimPost")
.leftJoin("Team", "ScrimPost.teamId", "Team.id")
.leftJoin("UserSubmittedImage", "Team.avatarImgId", "UserSubmittedImage.id")
.select((eb) => [
"ScrimPost.id",
"ScrimPost.at",
"ScrimPost.visibility",
"ScrimPost.maxDiv",
"ScrimPost.minDiv",
"ScrimPost.text",
jsonBuildObject({
name: eb.ref("Team.name"),
customUrl: eb.ref("Team.customUrl"),
avatarUrl: eb.ref("UserSubmittedImage.url"),
}).as("team"),
jsonArrayFrom(
eb
.selectFrom("ScrimPostUser")
.innerJoin("User", "ScrimPostUser.userId", "User.id")
.select([...COMMON_USER_FIELDS, "ScrimPostUser.isOwner"])
.whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id"),
).as("users"),
jsonArrayFrom(
eb
.selectFrom("ScrimPostRequest")
.leftJoin("Team", "ScrimPostRequest.teamId", "Team.id")
.leftJoin(
"UserSubmittedImage",
"Team.avatarImgId",
"UserSubmittedImage.id",
)
.select((innerEb) => [
"ScrimPostRequest.id",
"ScrimPostRequest.isAccepted",
"ScrimPostRequest.createdAt",
jsonBuildObject({
name: innerEb.ref("Team.name"),
customUrl: innerEb.ref("Team.customUrl"),
avatarUrl: innerEb.ref("UserSubmittedImage.url"),
}).as("team"),
jsonArrayFrom(
innerEb
.selectFrom("ScrimPostRequestUser")
.innerJoin("User", "ScrimPostRequestUser.userId", "User.id")
.select([...COMMON_USER_FIELDS, "ScrimPostRequestUser.isOwner"])
.whereRef(
"ScrimPostRequestUser.scrimPostRequestId",
"=",
"ScrimPostRequest.id",
),
).as("users"),
])
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id"),
).as("requests"),
]);
function findMany() {
const min = sub(new Date(), { hours: 3 });
return baseFindQuery
.orderBy("at", "asc")
.where("ScrimPost.at", ">=", dateToDatabaseTimestamp(min))
.execute();
}
const mapDBRowToScrimPost = (
row: Unwrapped<typeof findMany> & { chatCode?: string },
): ScrimPost => {
const someRequestIsAccepted = row.requests.some(
(request) => request.isAccepted,
);
// once one is accepted, rest are not relevant
const requests = someRequestIsAccepted
? row.requests.filter((request) => request.isAccepted)
: row.requests;
const ownerIds = row.users
.filter((user) => user.isOwner)
.map((user) => user.id);
return {
id: row.id,
at: row.at,
visibility: row.visibility,
text: row.text,
divs:
typeof row.maxDiv === "number" && typeof row.minDiv === "number"
? { max: parseLutiDiv(row.maxDiv), min: parseLutiDiv(row.minDiv) }
: null,
chatCode: row.chatCode ?? null,
team: row.team.name
? {
name: row.team.name,
customUrl: row.team.customUrl!,
avatarUrl: row.team.avatarUrl,
}
: null,
requests: requests.map((request) => {
return {
id: request.id,
isAccepted: Boolean(request.isAccepted),
createdAt: request.createdAt,
team: request.team.name
? {
name: request.team.name,
customUrl: request.team.customUrl!,
avatarUrl: request.team.avatarUrl,
}
: null,
users: request.users.map((user) => {
return {
...user,
isVerified: false,
isOwner: Boolean(user.isOwner),
};
}),
permissions: {
CANCEL: request.users.map((u) => u.id),
},
};
}),
users: row.users.map((user) => {
return {
...user,
isVerified: false,
isOwner: Boolean(user.isOwner),
};
}),
permissions: {
MANAGE_REQUESTS: ownerIds,
DELETE_POST: ownerIds,
},
};
};
export async function findById(scrimPostId: number): Promise<ScrimPost | null> {
const row = await baseFindQuery
.select(["ScrimPost.chatCode"])
.where("ScrimPost.id", "=", scrimPostId)
.executeTakeFirst();
if (!row) return null;
return mapDBRowToScrimPost(row);
}
export async function findAllRelevant(userId?: number): Promise<ScrimPost[]> {
const rows = await findMany();
const mapped = rows
.map(mapDBRowToScrimPost)
.filter(
(post) =>
!Scrim.isAccepted(post) ||
(userId && Scrim.isParticipating(post, userId)),
);
if (!userId) return mapped.map((post) => ({ ...post, requests: [] }));
return mapped.map(getPostRequestCensor(userId));
}
export function acceptRequest(scrimPostRequestId: number) {
return db
.updateTable("ScrimPostRequest")
.set({ isAccepted: 1 })
.where("id", "=", scrimPostRequestId)
.execute();
}
export function deleteRequest(scrimPostRequestId: number) {
return db
.deleteFrom("ScrimPostRequest")
.where("id", "=", scrimPostRequestId)
.execute();
}

View File

@@ -0,0 +1,154 @@
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
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";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
actionError,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { scrimsPage } from "~/utils/urls";
import * as QRepository from "../../sendouq/QRepository.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import {
type fromSchema,
type newRequestSchema,
scrimsNewActionSchema,
} from "../scrims-schemas";
import { serializeLutiDiv } from "../scrims-utils";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: scrimsNewActionSchema,
});
if (data.from.mode === "PICKUP") {
if (data.from.users.includes(user.id)) {
return actionError<typeof newRequestSchema>({
msg: "Don't add yourself to the pickup member list",
field: "from.root",
});
}
const pickupUserError = await validatePickup(data.from.users, user.id);
if (pickupUserError) {
return actionError<typeof newRequestSchema>({
msg: pickupUserError.error,
field: "from.root",
});
}
}
await ScrimPostRepository.insert({
at: dateToDatabaseTimestamp(data.at),
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
text: data.postText,
visibility:
data.baseVisibility !== "PUBLIC"
? {
forAssociation: data.baseVisibility,
notFoundInstructions: data.notFoundVisibility.at
? [
{
at: dateToDatabaseTimestamp(data.notFoundVisibility.at),
forAssociation:
data.notFoundVisibility.forAssociation !== "PUBLIC"
? data.notFoundVisibility.forAssociation
: null,
},
]
: undefined,
}
: null,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
users: (await usersListForPost({ authorId: user.id, from: data.from })).map(
(userId) => ({
userId,
isOwner: Number(user.id === userId),
}),
),
});
return redirect(scrimsPage());
};
const ROLES_TO_EXCLUDE: Tables["TeamMember"]["role"][] = [
"CHEERLEADER",
"COACH",
"SUB",
];
export const usersListForPost = async ({
from,
authorId,
}: { from: z.infer<typeof fromSchema>; authorId: number }) => {
if (from.mode === "PICKUP") {
return [authorId, ...from.users];
}
const teamId = from.teamId;
const team = (await TeamRepository.teamsByMemberUserId(authorId)).find(
(team) => team.id === teamId,
);
errorToastIfFalsy(team, "User is not a member of this team");
return team.members
.filter((member) => !ROLES_TO_EXCLUDE.includes(member.role))
.map((member) => member.id);
};
async function validatePickup(userIds: number[], authorId: number) {
const trustError = await validatePickupTrust(userIds, authorId);
if (trustError) {
return trustError;
}
const unbannedError = await validatePickupAllUnbanned(userIds);
if (unbannedError) {
return unbannedError;
}
return null;
}
async function validatePickupTrust(userIds: number[], authorId: number) {
const unconsentingUsers: string[] = [];
const trustedBy = await QRepository.usersThatTrusted(authorId);
for (const userId of userIds) {
const user = await UserRepository.findLeanById(userId);
invariant(user, "User not found");
if (
user.preferences?.disallowScrimPickupsFromUntrusted &&
!trustedBy.trusters.some((truster) => truster.id === userId)
) {
unconsentingUsers.push(user.username);
}
}
return unconsentingUsers.length === 0
? null
: {
error: `Following users don't allow untrusted to add: ${unconsentingUsers.join(", ")}. Ask them to add you to their trusted list.`,
};
}
async function validatePickupAllUnbanned(userIds: number[]) {
const bannedUsers = userIds.filter(userIsBanned);
return bannedUsers.length === 0
? null
: {
error: "Pickup includes banned users.",
};
}

View File

@@ -0,0 +1,146 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { notify } from "~/features/notifications/core/notify.server";
import { requirePermission } from "~/modules/permissions/requirePermission.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { scrimsActionSchema } from "../scrims-schemas";
import { usersListForPost } from "./scrims.new.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const data = await parseRequestPayload({
request,
schema: scrimsActionSchema,
});
switch (data._action) {
case "DELETE_POST": {
const post = await findPost({
userId: user.id,
postId: data.scrimPostId,
});
requirePermission(post, "DELETE_POST", user);
await ScrimPostRepository.del(post.id);
break;
}
case "NEW_REQUEST": {
const post = await findPost({
userId: user.id,
postId: data.scrimPostId,
});
await ScrimPostRepository.insertRequest({
scrimPostId: data.scrimPostId,
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
users: (
await usersListForPost({ authorId: user.id, from: data.from })
).map((userId) => ({
userId,
isOwner: Number(user.id === userId),
})),
});
notify({
userIds: post.users
.filter((user) => user.isOwner)
.map((user) => user.id),
notification: {
type: "SCRIM_NEW_REQUEST",
meta: {
fromUsername: user.username,
},
},
});
break;
}
case "ACCEPT_REQUEST": {
const { post, request } = await findRequest({
userId: user.id,
requestId: data.scrimPostRequestId,
});
requirePermission(post, "MANAGE_REQUESTS", user);
errorToastIfFalsy(!request.isAccepted, "Request is already accepted");
await ScrimPostRepository.acceptRequest(data.scrimPostRequestId);
notify({
userIds: [
...post.users.map((m) => m.id),
...request.users.map((m) => m.id),
],
defaultSeenUserIds: [user.id],
notification: {
type: "SCRIM_SCHEDULED",
meta: {
id: post.id,
timeString: databaseTimestampToDate(post.at).toLocaleString(
"en-US",
{
day: "numeric",
month: "numeric",
hour: "numeric",
minute: "numeric",
},
),
},
},
});
break;
}
case "CANCEL_REQUEST": {
const { request } = await findRequest({
userId: user.id,
requestId: data.scrimPostRequestId,
});
requirePermission(request, "CANCEL", user);
errorToastIfFalsy(
!request.isAccepted,
"Can't cancel an accepted request",
);
await ScrimPostRepository.deleteRequest(data.scrimPostRequestId);
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};
async function findPost({
userId,
postId,
}: { userId: number; postId: number }) {
const posts = await ScrimPostRepository.findAllRelevant(userId);
const post = posts.find((post) => post.id === postId);
errorToastIfFalsy(post, "Post not found");
return post;
}
async function findRequest({
userId,
requestId,
}: { userId: number; requestId: number }) {
const posts = await ScrimPostRepository.findAllRelevant(userId);
const post = posts.find((post) =>
post.requests.some((request) => request.id === requestId),
);
const request = post?.requests.find((request) => request.id === requestId);
errorToastIfFalsy(post && request, "Request not found");
return { post, request };
}

View File

@@ -0,0 +1,112 @@
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { UserSearch } from "~/components/UserSearch";
import { useUser } from "~/features/auth/core/user";
import { SCRIM } from "~/features/scrims/scrims-constants";
import { nullFilledArray } from "~/utils/arrays";
import type { CommonUser } from "~/utils/kysely.server";
import type { NewRequestFormFields } from "../routes/scrims";
interface FromFormFieldProps {
usersTeams: Array<{
id: number;
name: string;
members: Array<CommonUser>;
}>;
}
export function WithFormField({ usersTeams }: FromFormFieldProps) {
const { t } = useTranslation(["scrims"]);
const user = useUser();
const methods = useFormContext<NewRequestFormFields>();
return (
<div>
<Label htmlFor="with">{t("scrims:forms.with.title")}</Label>
<Controller
control={methods.control}
name="from"
render={({ field: { onChange, onBlur, value }, fieldState }) => {
const setTeam = (teamId: number) => {
onChange({ teamId, mode: "TEAM" });
};
const error =
(fieldState.error as any)?.users ?? fieldState.error?.root;
return (
<div>
<select
id="with"
className="w-max"
value={value.mode === "TEAM" ? value.teamId : "PICKUP"}
onChange={(e) => {
if (e.target.value === "PICKUP") {
onChange({
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
),
});
return;
}
setTeam(Number(e.target.value));
}}
onBlur={onBlur}
>
{usersTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
</select>
{value.mode === "PICKUP" ? (
<div className="stack md mt-4">
<div>
<Label required>
{t("scrims:forms.with.user", { nth: 1 })}
</Label>
<UserSearch initialUserId={user!.id} disabled />
</div>
{value.users.map((userId, i) => (
<div key={i}>
<Label required={i < 3} htmlFor={`user-${i}`}>
{t("scrims:forms.with.user", { nth: i + 2 })}
</Label>
<UserSearch
id={`user-${i}`}
// TODO: changing it like this triggers useEffect -> dropdown stays open, need to use "value" not "defaultValue"
initialUserId={userId ?? undefined}
onChange={(user) =>
onChange({
mode: "PICKUP",
users: value.users.map((u, j) =>
j === i ? user.id : u,
),
})
}
/>
</div>
))}
{error ? (
<FormMessage type="error">
{error.message as string}
</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.with.explanation")}
</FormMessage>
)}
</div>
) : null}
</div>
);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { ScrimPost } from "../scrims-types";
/** Returns true if the original poster has accepted any of the requests. */
export function isAccepted(post: ScrimPost) {
return post.requests.some((request) => request.isAccepted);
}
/** Returns true if the user is participating in the scrim, either in the original post users list or the request. */
export function isParticipating(post: ScrimPost, userId: number) {
return (
post.requests.some((request) =>
request.users.some((user) => user.id === userId),
) || post.users.some((user) => user.id === userId)
);
}
export function resolvePoolCode(postId: number) {
return `SC${postId % 10}`;
}

View File

@@ -0,0 +1,33 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isMod } from "../../../permissions";
import { notFoundIfFalsy } from "../../../utils/remix.server";
import { requireUser } from "../../auth/core/user.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import * as Scrim from "../core/Scrim";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await requireUser(request);
const post = notFoundIfFalsy(
await ScrimPostRepository.findById(Number(params.id)),
);
if (!Scrim.isAccepted(post)) {
throw new Response(null, { status: 404 });
}
if (!Scrim.isParticipating(post, user.id) && !isMod(user)) {
throw new Response(null, { status: 403 });
}
return {
post,
chatUsers: await UserRepository.findChatUsersByUserIds(
post.users.map((u) => u.id),
),
};
};

View File

@@ -0,0 +1,20 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as AssociationRepository from "~/features/associations/AssociationRepository.server";
import { requireUserId } from "~/features/auth/core/user.server";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy } from "~/utils/remix.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
export type ScrimsNewLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ request }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await requireUserId(request);
return {
teams: await TeamRepository.teamsByMemberUserId(user.id),
associations: await AssociationRepository.findByMemberUserId(user.id),
};
};

View File

@@ -0,0 +1,45 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
import * as Association from "~/features/associations/core/Association";
import { getUser } from "~/features/auth/core/user.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import * as Scrim from "../core/Scrim";
import { FF_SCRIMS_ENABLED } from "../scrims-constants";
import { dividePosts } from "../scrims-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
notFoundIfFalsy(FF_SCRIMS_ENABLED);
const user = await getUser(request);
const now = new Date();
const associations = user
? await AssociationsRepository.findByMemberUserId(user?.id)
: null;
const posts = (await ScrimPostRepository.findAllRelevant(user?.id))
.filter(
(post) =>
(user && Scrim.isParticipating(post, user.id)) ||
Association.isVisible({
associations,
time: now,
visibility: post.visibility,
}),
)
.map((post) => ({
...post,
visibility: null,
isPrivate: !Association.isPublic({
time: now,
visibility: post.visibility,
}),
}));
return {
posts: dividePosts(posts, user?.id),
teams: user ? await TeamRepository.teamsByMemberUserId(user.id) : [],
};
};

View File

@@ -0,0 +1,50 @@
.groupsContainer {
display: grid;
grid-template-columns: 1fr;
gap: var(--s-8);
}
@media screen and (min-width: 640px) {
.groupsContainer {
grid-template-columns: 1fr 1fr;
}
}
.groupCard {
border-radius: var(--rounded);
background-color: var(--bg-lighter-solid);
padding: var(--s-2-5);
display: flex;
gap: var(--s-4);
flex-direction: column;
}
.memberRow {
display: flex;
gap: var(--s-2);
align-items: center;
background-color: var(--bg-darker);
border-radius: var(--rounded);
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
padding-inline-end: var(--s-2-5);
}
.infoHeader {
text-transform: uppercase;
color: var(--text-lighter);
font-size: var(--fonts-xs);
line-height: 1.1;
}
.infoValue {
font-size: var(--fonts-xl);
font-weight: var(--semi-bold);
letter-spacing: 1px;
}
.chatContainer {
border-radius: var(--rounded);
background-color: var(--bg-lighter-solid);
padding: var(--s-2-5);
}

View File

@@ -0,0 +1,146 @@
import { Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Avatar } from "../../../components/Avatar";
import { Main } from "../../../components/Main";
import { databaseTimestampToDate } from "../../../utils/dates";
import { logger } from "../../../utils/logger";
import { teamPage, userSubmittedImage } from "../../../utils/urls";
import { ConnectedChat } from "../../chat/components/Chat";
import * as Scrim from "../core/Scrim";
import type { ScrimPost as ScrimPostType } from "../scrims-types";
import { loader } from "../loaders/scrims.$id.server";
export { loader };
import styles from "./scrims.$id.module.css";
export const handle: SendouRouteHandle = {
i18n: ["scrims", "q"],
};
export default function ScrimPage() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<ScrimHeader />
<div className={styles.groupsContainer}>
<GroupCard group={data.post} side="ALPHA" />
<GroupCard group={data.post.requests[0]} side="BRAVO" />
</div>
<div className="stack horizontal lg justify-center">
<InfoWithHeader
header={t("q:match.password.short")}
value={resolveRoomPass(data.post.id)}
/>
<InfoWithHeader
header={t("q:match.pool")}
value={Scrim.resolvePoolCode(data.post.id)}
/>
</div>
<ScrimChat />
</Main>
);
}
function ScrimHeader() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const { i18n } = useTranslation();
return (
<div className="line-height-tight" data-testid="match-header">
<h2 className="text-lg" suppressHydrationWarning>
{databaseTimestampToDate(data.post.at).toLocaleString(i18n.language, {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "numeric",
})}
</h2>
<div className="text-lighter text-xs font-bold">
{t("scrims:page.scheduledScrim")}
</div>
</div>
);
}
function GroupCard({
group,
side,
}: {
group: { users: ScrimPostType["users"]; team: ScrimPostType["team"] };
side: "ALPHA" | "BRAVO";
}) {
const { t } = useTranslation(["q"]);
return (
<div className="stack sm">
<div className="stack horizontal justify-between">
<div className="text-lighter text-xs">
{side === "ALPHA"
? t("q:match.sides.alpha")
: t("q:match.sides.bravo")}
</div>
{group.team ? (
<Link
to={teamPage(group.team.customUrl)}
className="stack horizontal items-center xs font-bold text-xs"
>
{group.team.avatarUrl ? (
<Avatar
url={userSubmittedImage(group.team.avatarUrl)}
size="xxs"
/>
) : null}
{group.team.name}
</Link>
) : null}
</div>
<div className={styles.groupCard}>
{group.users.map((user) => (
<div key={user.id} className={styles.memberRow}>
<Avatar user={user} size="xs" />
{user.username}
</div>
))}
</div>
</div>
);
}
function InfoWithHeader({ header, value }: { header: string; value: string }) {
return (
<div>
<div className={styles.infoHeader}>{header}</div>
<div className={styles.infoValue}>{value}</div>
</div>
);
}
function ScrimChat() {
const data = useLoaderData<typeof loader>();
const chatCode = data.post.chatCode;
if (!chatCode) {
logger.warn("No chat code found");
return null;
}
const rooms = React.useMemo(
() => [{ label: "Scrim", code: chatCode }],
[chatCode],
);
return (
<div className={styles.chatContainer}>
<ConnectedChat users={data.chatUsers} rooms={rooms} />
</div>
);
}

View File

@@ -0,0 +1,59 @@
.placeholder {
min-height: 100vh;
}
.postTime {
border-radius: var(--rounded-sm);
background-color: var(--border);
text-transform: uppercase;
padding: var(--s-0-5) var(--s-2);
font-weight: var(--bold);
width: max-content;
}
.postPrivateCell {
min-width: 24px;
}
.postPrivateCell svg {
width: 18px;
}
.postIcon {
min-width: 24px;
}
.postStatus {
display: flex;
gap: var(--s-1);
font-weight: var(--semi-bold);
padding-block: var(--s-1);
padding-inline: var(--s-2);
width: max-content;
border-radius: var(--rounded-sm);
}
.postStatusConfirmed {
background-color: var(--theme-success-transparent);
}
.postStatus svg {
width: 18px;
}
.postStatusConfirmed svg {
fill: var(--theme-success);
}
.postStatusPending {
background-color: var(--theme-info-transparent);
}
.postStatusPending svg {
fill: var(--theme-info);
}
.postFloatingActionCell {
position: sticky;
right: 0;
}

View File

@@ -0,0 +1,273 @@
import { useLoaderData } from "@remix-run/react";
import * as React from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { Label } from "~/components/Label";
import { DateTimeFormField } from "~/components/form/DateTimeFormField";
import { MyForm } from "~/components/form/MyForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { nullFilledArray } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
import { Main } from "../../../components/Main";
import { WithFormField } from "../components/WithFormField";
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import {
MAX_SCRIM_POST_TEXT_LENGTH,
scrimsNewActionSchema,
} from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
import { action } from "../actions/scrims.new.server";
import { type ScrimsNewLoaderData, loader } from "../loaders/scrims.new.server";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
type FormFields = z.infer<typeof scrimsNewActionSchema>;
export default function NewScrimPage() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
return (
<Main>
<MyForm
schema={scrimsNewActionSchema}
title={t("scrims:forms.title")}
defaultValues={{
postText: "",
at: new Date(),
divs: null,
baseVisibility: "PUBLIC",
notFoundVisibility: {
at: null,
forAssociation: "PUBLIC",
},
from:
data.teams.length > 0
? { mode: "TEAM", teamId: data.teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
}}
>
<WithFormField usersTeams={data.teams} />
<DateTimeFormField<FormFields>
label={t("scrims:forms.when.title")}
name="at"
bottomText={t("scrims:forms.when.explanation")}
/>
<BaseVisibilityFormField associations={data.associations} />
<NotFoundVisibilityFormField associations={data.associations} />
<LutiDivsFormField />
<TextAreaFormField<FormFields>
label={t("scrims:forms.text.title")}
name="postText"
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
/>
</MyForm>
</Main>
);
}
function BaseVisibilityFormField({
associations,
}: { associations: ScrimsNewLoaderData["associations"] }) {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const error = methods.formState.errors.baseVisibility;
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
return (
<div>
<Label htmlFor="visibility">{t("scrims:forms.visibility.title")}</Label>
{noAssociations ? (
<FormMessage type="info">
{t("scrims:forms.visibility.noneAvailable")}
</FormMessage>
) : (
<AssociationSelect
associations={associations}
id="visibility"
{...methods.register("baseVisibility")}
/>
)}
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
function NotFoundVisibilityFormField({
associations,
}: { associations: ScrimsNewLoaderData["associations"] }) {
const { t } = useTranslation(["scrims"]);
const date = useWatch<FormFields>({ name: "notFoundVisibility.at" }) ?? "";
const methods = useFormContext<FormFields>();
const error = methods.formState.errors.notFoundVisibility;
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
if (noAssociations) return null;
return (
<div>
<div className="stack horizontal sm">
<DateTimeFormField<FormFields>
label={t("scrims:forms.notFoundVisibility.title")}
name="notFoundVisibility.at"
/>
{date ? (
<div>
<Label htmlFor="not-found-visibility">
{t("scrims:forms.visibility.title")}
</Label>
<AssociationSelect
associations={associations}
id="not-found-visibility"
{...methods.register("notFoundVisibility.forAssociation")}
/>
</div>
) : null}
</div>
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.notFoundVisibility.explanation")}
</FormMessage>
)}
</div>
);
}
const AssociationSelect = React.forwardRef<
HTMLSelectElement,
{
associations: ScrimsNewLoaderData["associations"];
} & React.SelectHTMLAttributes<HTMLSelectElement>
>(({ associations, ...rest }, ref) => {
const { t } = useTranslation(["scrims"]);
return (
<select ref={ref} {...rest}>
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
{associations.virtual.map((association) => (
<option key={association} value={association}>
{association}
</option>
))}
{associations.actual.map((association) => (
<option key={association.id} value={association.id}>
{association.name}
</option>
))}
</select>
);
});
function LutiDivsFormField() {
const methods = useFormContext<FormFields>();
const error = methods.formState.errors.divs;
return (
<div>
<Controller
control={methods.control}
name="divs"
render={({ field: { onChange, onBlur, value } }) => (
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
type LutiDivEdit = {
max: LutiDiv | null;
min: LutiDiv | null;
};
function LutiDivsSelector({
value,
onChange,
onBlur,
}: {
value: LutiDivEdit | null;
onChange: (value: LutiDivEdit | null) => void;
onBlur: () => void;
}) {
const { t } = useTranslation(["scrims"]);
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.max
? { min: newValue, max: value?.max ?? null }
: null,
);
};
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.min
? { max: newValue, min: value?.min ?? null }
: null,
);
};
return (
<div className="stack horizontal sm">
<div>
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
<select id="min-div" onChange={onChangeMin} onBlur={onBlur}>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
<select id="max-div" onChange={onChangeMax} onBlur={onBlur}>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,668 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import * as R from "remeda";
import type { z } from "zod";
import { Avatar } from "~/components/Avatar";
import { Button, LinkButton } from "~/components/Button";
import { Dialog } from "~/components/Dialog";
import { Divider } from "~/components/Divider";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Table } from "~/components/Table";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { MyForm } from "~/components/form/MyForm";
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
import { SpeechBubbleIcon } from "~/components/icons/SpeechBubble";
import { UsersIcon } from "~/components/icons/Users";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { joinListToNaturalString, nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
associationsPage,
scrimPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { Main } from "../../../components/Main";
import { NewTabs } from "../../../components/NewTabs";
import { ArrowDownOnSquareIcon } from "../../../components/icons/ArrowDownOnSquare";
import { ArrowUpOnSquareIcon } from "../../../components/icons/ArrowUpOnSquare";
import { CheckmarkIcon } from "../../../components/icons/Checkmark";
import { ClockIcon } from "../../../components/icons/Clock";
import { CrossIcon } from "../../../components/icons/Cross";
import { MegaphoneIcon } from "../../../components/icons/MegaphoneIcon";
import { SpeechBubbleFilledIcon } from "../../../components/icons/SpeechBubbleFilled";
import { WithFormField } from "../components/WithFormField";
import { SCRIM } from "../scrims-constants";
import { newRequestSchema } from "../scrims-schemas";
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
import { action } from "../actions/scrims.server";
import { loader } from "../loaders/scrims.server";
export { loader, action };
import styles from "./scrims.module.css";
export type NewRequestFormFields = z.infer<typeof newRequestSchema>;
export const handle: SendouRouteHandle = {
i18n: ["calendar", "scrims"],
};
export const meta: MetaFunction<typeof loader> = (args) => {
return metaTags({
title: "Scrims",
ogTitle: "Splatoon scrim finder",
description:
"Schedule scrims against competitive teams. Make your own post or browse available scrims.",
location: args.location,
});
};
export default function ScrimsPage() {
const user = useUser();
const { t } = useTranslation(["calendar", "scrims"]);
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const [scrimToRequestId, setScrimToRequestId] = React.useState<number>();
// biome-ignore lint/correctness/useExhaustiveDependencies: clear modal on submit
React.useEffect(() => {
setScrimToRequestId(undefined);
}, [data]);
if (!isMounted)
return (
<Main>
<div className={styles.placeholder} />
</Main>
);
return (
<Main className="stack lg">
{user ? (
<LinkButton
size="tiny"
to={associationsPage()}
className="mr-auto"
variant="outlined"
>
{t("scrims:associations.title")}
</LinkButton>
) : null}
{typeof scrimToRequestId === "number" ? (
<RequestScrimModal
postId={scrimToRequestId}
close={() => setScrimToRequestId(undefined)}
/>
) : null}
<NewTabs
sticky
disappearing
defaultIndex={data.posts.owned.length > 0 ? 0 : 2}
tabs={[
{
label: t("scrims:tabs.owned"),
number: data.posts.owned.length,
icon: <ArrowDownOnSquareIcon />,
},
{
label: t("scrims:tabs.requests"),
number: data.posts.requested.length,
icon: <ArrowUpOnSquareIcon />,
},
{
label: t("scrims:tabs.available"),
number: data.posts.neutral.length,
icon: <MegaphoneIcon />,
},
]}
content={[
{
key: "owned",
element: (
<ScrimsDaySeparatedTables
posts={data.posts.owned}
showDeletePost
showRequestRows
/>
),
},
{
key: "requested",
element: (
<ScrimsDaySeparatedTables
posts={data.posts.requested}
requestScrim={setScrimToRequestId}
showStatus
/>
),
},
{
key: "available",
element:
data.posts.neutral.length > 0 ? (
<ScrimsDaySeparatedTables
posts={data.posts.neutral}
requestScrim={setScrimToRequestId}
/>
) : (
<div className="text-lighter text-lg font-semi-bold text-center mt-6">
{t("scrims:noneAvailable")}
</div>
),
},
]}
/>
<div className="mt-6 text-xs text-center text-lighter">
{t("calendar:inYourTimeZone")}{" "}
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</div>
</Main>
);
}
function RequestScrimModal({
postId,
close,
}: { postId: number; close: () => void }) {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
// both to avoid crash when requesting
const post = [...data.posts.neutral, ...data.posts.requested].find(
(post) => post.id === postId,
);
invariant(post, "Post not found");
return (
<Dialog isOpen>
<MyForm
schema={newRequestSchema}
title={t("scrims:requestModal.title")}
defaultValues={{
_action: "NEW_REQUEST",
scrimPostId: postId,
from:
data.teams.length > 0
? { mode: "TEAM", teamId: data.teams[0].id }
: {
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
) as unknown as number[],
},
}}
handleCancel={close}
>
<ScrimsDaySeparatedTables posts={[post]} showPopovers={false} />
<div className="font-semi-bold text-lighter italic">
{joinListToNaturalString(post.users.map((u) => u.username))}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
</MyForm>
</Dialog>
);
}
function ScrimsDaySeparatedTables({
posts,
showPopovers = true,
showDeletePost = false,
showRequestRows = false,
showStatus = false,
requestScrim,
}: {
posts: ScrimPost[];
showPopovers?: boolean;
showDeletePost?: boolean;
showRequestRows?: boolean;
showStatus?: boolean;
requestScrim?: (postId: number) => void;
}) {
const { i18n } = useTranslation();
const postsByDay = R.groupBy(posts, (post) =>
databaseTimestampToDate(post.at).getDate(),
);
return (
<div className="stack lg">
{Object.entries(postsByDay)
.sort((a, b) => a[1][0].at - b[1][0].at)
.map(([day, posts]) => {
return (
<div key={day} className="stack md">
<h2 className="text-sm">
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
i18n.language,
{
day: "numeric",
month: "long",
weekday: "long",
},
)}
</h2>
<ScrimsTable
posts={posts!}
requestScrim={requestScrim}
showDeletePost={showDeletePost}
showRequestRows={showRequestRows}
showPopovers={showPopovers}
showStatus={showStatus}
/>
</div>
);
})}
</div>
);
}
function ScrimsTable({
posts,
showPopovers,
showDeletePost,
showRequestRows,
showStatus,
requestScrim,
}: {
posts: ScrimPost[];
showPopovers: boolean;
showDeletePost: boolean;
showRequestRows: boolean;
showStatus: boolean;
requestScrim?: (postId: number) => void;
}) {
const { t } = useTranslation(["common", "scrims"]);
const user = useUser();
const { i18n } = useTranslation();
invariant(
!(requestScrim && showDeletePost),
"Can't have both request scrim and delete post",
);
const getStatus = (post: ScrimPost) => {
if (post.requests.at(0)?.isAccepted) return "CONFIRMED";
if (
post.requests.some((r) => r.users.some((rUser) => user?.id === rUser.id))
) {
return "PENDING";
}
return null;
};
return (
<Table>
<thead>
<tr>
<th>{t("scrims:table.headers.time")}</th>
<th>{t("scrims:table.headers.team")}</th>
{showPopovers ? <th /> : null}
<th>{t("scrims:table.headers.divs")}</th>
{showStatus ? <th>{t("scrims:table.headers.status")}</th> : null}
{requestScrim || showDeletePost ? <th /> : null}
</tr>
</thead>
<tbody>
{posts.map((post) => {
const owner =
post.users.find((user) => user.isOwner) ?? post.users[0];
const date = databaseTimestampToDate(post.at);
const inThePast = date < new Date();
const requests = showRequestRows
? post.requests.map((request) => (
<RequestRow
key={request.id}
canAccept={Boolean(
user && post.permissions.MANAGE_REQUESTS.includes(user.id),
)}
request={request}
postId={post.id}
/>
))
: [];
const isAccepted = post.requests.some(
(request) => request.isAccepted,
);
const showContactButton =
isAccepted &&
post.requests.at(0)?.users.some((rUser) => rUser.id === user?.id);
const status = getStatus(post);
return (
<React.Fragment key={post.id}>
<tr>
<td>
<div className="stack horizontal sm">
<div className={styles.postTime}>
{inThePast
? t("scrims:now")
: databaseTimestampToDate(post.at).toLocaleTimeString(
i18n.language,
{
hour: "numeric",
minute: "numeric",
},
)}
</div>
{post.isPrivate ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<EyeSlashIcon className={styles.postIcon} />}
data-testid="limited-visibility-popover"
/>
}
>
{t("scrims:limitedVisibility")}
</SendouPopover>
) : null}
</div>
</td>
<td>
<div className="stack horizontal sm items-center min-w-max">
{showPopovers ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={<UsersIcon className={styles.postIcon} />}
/>
}
>
<div className="stack md">
{post.users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
))}
</div>
</SendouPopover>
) : null}
{post.team?.avatarUrl ? (
<Avatar
size="xxs"
url={userSubmittedImage(post.team.avatarUrl)}
/>
) : (
<Avatar size="xxs" user={owner} />
)}
{post.team?.name ??
t("scrims:pickup", { username: owner.username })}
</div>
</td>
{showPopovers ? (
<td>
{post.text ? (
<SendouPopover
trigger={
<SendouButton
variant="minimal"
icon={
<SpeechBubbleIcon className={styles.postIcon} />
}
data-testid="scrim-text-popover"
/>
}
>
{post.text}
</SendouPopover>
) : null}
</td>
) : null}
<td className="whitespace-nowrap">
{post.divs ? (
<>
{post.divs.max} - {post.divs.min}
</>
) : null}
</td>
{showStatus ? (
<td
className={clsx({
[styles.postFloatingActionCell]: status !== "CONFIRMED",
})}
>
<div
className={clsx(styles.postStatus, {
[styles.postStatusConfirmed]: status === "CONFIRMED",
[styles.postStatusPending]: status === "PENDING",
})}
>
{status === "CONFIRMED" ? (
<>
<CheckmarkIcon /> {t("scrims:status.booked")}
</>
) : null}
{status === "PENDING" ? (
<>
<ClockIcon /> {t("scrims:status.pending")}
</>
) : null}
</div>
</td>
) : null}
{requestScrim && post.requests.length === 0 ? (
<td className={styles.postFloatingActionCell}>
<Button
size="tiny"
onClick={() => requestScrim(post.id)}
icon={<ArrowUpOnSquareIcon />}
className="ml-auto"
>
{t("scrims:actions.request")}
</Button>
</td>
) : null}
{showDeletePost && !isAccepted ? (
<td>
{user && post.permissions.DELETE_POST.includes(user.id) ? (
<FormWithConfirm
dialogHeading={t("scrims:deleteModal.title")}
submitButtonText={t("common:actions.delete")}
cancelButtonText={t("common:actions.nevermind")}
fields={[
["scrimPostId", post.id],
["_action", "DELETE_POST"],
]}
>
<Button
size="tiny"
variant="destructive"
className="ml-auto"
>
{t("common:actions.delete")}
</Button>
</FormWithConfirm>
) : (
<SendouPopover
trigger={
<SendouButton
variant="destructive"
size="small"
className="ml-auto"
>
{t("common:actions.delete")}
</SendouButton>
}
>
{t("scrims:deleteModal.prevented")}
</SendouPopover>
)}
</td>
) : null}
{user &&
requestScrim &&
post.requests.length !== 0 &&
!post.requests.at(0)?.isAccepted &&
post.requests.at(0)?.permissions.CANCEL.includes(user.id) ? (
<td>
<FormWithConfirm
dialogHeading={t("scrims:cancelModal.title")}
submitButtonText={t("common:actions.cancel")}
cancelButtonText={t("common:actions.nevermind")}
fields={[
["scrimPostRequestId", post.requests[0].id],
["_action", "CANCEL_REQUEST"],
]}
>
<Button
size="tiny"
variant="destructive"
icon={<CrossIcon />}
className="ml-auto"
>
{t("common:actions.cancel")}
</Button>
</FormWithConfirm>
</td>
) : null}
{showContactButton ? (
<td className={styles.postFloatingActionCell}>
<ContactButton postId={post.id} />
</td>
) : null}
{isAccepted &&
post.requests.some(
(r) =>
r.isAccepted && !r.users.some((u) => u.id === user?.id),
) ? (
<td />
) : null}
</tr>
{requests}
</React.Fragment>
);
})}
</tbody>
</Table>
);
}
function ContactButton({ postId }: { postId: number }) {
const { t } = useTranslation(["scrims"]);
return (
<LinkButton
to={scrimPage(postId)}
size="tiny"
className="w-max ml-auto"
icon={<SpeechBubbleFilledIcon />}
>
{t("scrims:actions.contact")}
</LinkButton>
);
}
function RequestRow({
canAccept,
request,
postId,
}: { canAccept: boolean; request: ScrimPostRequest; postId: number }) {
const { t } = useTranslation(["common", "scrims"]);
const requestOwner =
request.users.find((user) => user.isOwner) ?? request.users[0];
const groupName =
request.team?.name ??
t("scrims:pickup", {
username: requestOwner.username,
});
return (
<tr className="bg-theme-transparent-important">
<td />
<td>
<div className="stack horizontal sm items-center">
<SendouPopover
trigger={
<SendouButton
icon={<UsersIcon className={styles.postIcon} />}
variant="minimal"
/>
}
>
<div className="stack md">
{request.users.map((user) => (
<Link
to={userPage(user)}
key={user.id}
className="stack horizontal sm"
>
<Avatar size="xxs" user={user} />
{user.username}
</Link>
))}
</div>
</SendouPopover>
{request.team?.avatarUrl ? (
<Avatar
size="xxs"
url={userSubmittedImage(request.team.avatarUrl)}
/>
) : (
<Avatar size="xxs" user={requestOwner} />
)}
{groupName}
</div>
</td>
<td />
<td />
<td className={styles.postFloatingActionCell}>
{!request.isAccepted && canAccept ? (
<FormWithConfirm
dialogHeading={t("scrims:acceptModal.title", { groupName })}
fields={[
["scrimPostRequestId", request.id],
["_action", "ACCEPT_REQUEST"],
]}
submitButtonVariant="primary"
submitButtonText={t("common:actions.accept")}
cancelButtonVariant="destructive"
>
<Button size="tiny" className="ml-auto">
{t("common:actions.accept")}
</Button>
</FormWithConfirm>
) : !request.isAccepted && !canAccept ? (
<SendouPopover
trigger={
<SendouButton size="small">
{t("common:actions.accept")}
</SendouButton>
}
>
{t("scrims:acceptModal.prevented")}
</SendouPopover>
) : (
<ContactButton postId={postId} />
)}
</td>
</tr>
);
}

View File

@@ -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";

View File

@@ -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,
});
}
});

View File

@@ -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<ScrimPostUser>;
chatCode: string | null;
requests: Array<ScrimPostRequest>;
/** 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<ScrimPostUser>;
team: ScrimPostTeam | null;
permissions: {
CANCEL: number[];
};
createdAt: number;
}
interface ScrimPostUser extends CommonUser {
isOwner: boolean;
}
interface ScrimPostTeam {
name: string;
customUrl: string;
avatarUrl: string | null;
}

View File

@@ -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<ScrimPost>, 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);
};

View File

@@ -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}
>

View File

@@ -611,7 +611,7 @@ function TrustedUsers() {
["_action", "REMOVE_TRUST"],
["userToRemoveTrustFromId", trustedUser.id],
]}
deleteButtonText="Remove"
submitButtonText="Remove"
>
<Button
className="build__small-text"

View File

@@ -278,6 +278,10 @@ export function deletePrivateUserNote({
.execute();
}
/**
* Retrieves information about users who have trusted the specified user,
* including their associated teams and explicit trust relationships. Banned users are excluded.
*/
export async function usersThatTrusted(userId: number) {
const teams = await db
.selectFrom("TeamMemberWithSecondary")

View File

@@ -18,7 +18,7 @@ export function GroupLeaver({
<FormWithConfirm
dialogHeading="Leave this group?"
fields={[["_action", "LEAVE_GROUP"]]}
deleteButtonText="Leave"
submitButtonText="Leave"
action={SENDOUQ_LOOKING_PAGE}
>
<Button variant="minimal-destructive" size="tiny">

View File

@@ -19,6 +19,12 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED": {
await UserRepository.updatePreferences(user.id, {
disallowScrimPickupsFromUntrusted: data.newValue,
});
break;
}
case "PLACEHOLDER": {
break;
}

View File

@@ -8,6 +8,7 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SendouSwitch } from "~/components/elements/Switch";
import { useUser } from "~/features/auth/core/user";
import { FF_SCRIMS_ENABLED } from "~/features/scrims/scrims-constants";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
@@ -53,6 +54,20 @@ export default function SettingsPage() {
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
{FF_SCRIMS_ENABLED ? (
<PreferenceSelectorSwitch
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
defaultSelected={
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
}
label={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
)}
bottomText={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
)}
/>
) : null}
</div>
</>
) : null}

View File

@@ -6,6 +6,10 @@ export const settingsEditSchema = z.union([
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: z.boolean(),
}),
z.object({
_action: _action("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: z.boolean(),
}),
z.object({
_action: _action("PLACEHOLDER"),
}),

View File

@@ -109,10 +109,19 @@ export async function teamsByMemberUserId(
) {
return (trx ?? db)
.selectFrom("TeamMemberWithSecondary")
.select([
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.select((eb) => [
"TeamMemberWithSecondary.teamId as id",
"Team.name",
"TeamMemberWithSecondary.isOwner",
"TeamMemberWithSecondary.isMainTeam",
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary as m2")
.innerJoin("User", "User.id", "m2.userId")
.select([...COMMON_USER_FIELDS, "m2.role"])
.whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId"),
).as("members"),
])
.where("userId", "=", userId)
.execute();

View File

@@ -237,7 +237,7 @@ function MemberRow({
teamName: team.name,
user: member.username,
})}
deleteButtonText={t("team:actionButtons.kick")}
submitButtonText={t("team:actionButtons.kick")}
fields={[
["_action", "DELETE_MEMBER"],
["userId", member.id],

View File

@@ -217,7 +217,7 @@ function ActionButtons() {
newOwner: resolveNewOwner(team.members)?.username,
},
)}`}
deleteButtonText={t("team:actionButtons.leaveTeam.confirm")}
submitButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
<Button

View File

@@ -99,7 +99,7 @@ function UnlinkFormWithButton() {
return (
<FormWithConfirm
dialogHeading={t("common:xsearch.unlink.title")}
deleteButtonText={t("common:xsearch.unlink.action.short")}
submitButtonText={t("common:xsearch.unlink.action.short")}
>
<SendouButton
icon={<UnlinkIcon />}

View File

@@ -169,7 +169,7 @@ export default function TournamentBracketsPage() {
<FormWithConfirm
dialogHeading={t("tournament:actions.finalize.confirm")}
fields={[["_action", "FINALIZE_TOURNAMENT"]]}
deleteButtonText={t("tournament:actions.finalize.action")}
submitButtonText={t("tournament:actions.finalize.action")}
submitButtonVariant="outlined"
>
<Button variant="minimal" testId="finalize-tournament-button">

View File

@@ -10,7 +10,7 @@ import {
} from "~/utils/remix.server";
import { tournamentOrganizationPage } from "~/utils/urls";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { organizationEditSchema } from "../routes/org.$slug.edit";
import { organizationEditSchema } from "../tournament-organization-schemas";
import { canEditTournamentOrganization } from "../tournament-organization-utils";
import { organizationFromParams } from "../tournament-organization-utils.server";

View File

@@ -1,7 +1,7 @@
import { Link, useLoaderData } from "@remix-run/react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import type { z } from "zod";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
@@ -18,84 +18,14 @@ import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { wrapToValueStringArrayWithDefault } from "~/utils/form";
import type { Unpacked } from "~/utils/types";
import { mySlugify, uploadImagePage } from "~/utils/urls";
import { falsyToNull, id } from "~/utils/zod";
import { uploadImagePage } from "~/utils/urls";
import { organizationEditSchema } from "../tournament-organization-schemas";
import { action } from "../actions/org.$slug.edit.server";
import { loader } from "../loaders/org.$slug.edit.server";
import { handle, meta } from "../routes/org.$slug";
export { loader, action, handle, meta };
const DESCRIPTION_MAX_LENGTH = 1_000;
export const organizationEditSchema = z.object({
name: z
.string()
.trim()
.min(2)
.max(32)
.refine((val) => mySlugify(val).length >= 2, {
message: "Not enough non-special characters",
}),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
),
members: z
.array(
z.object({
userId: z.number().int().positive(),
role: z.enum(TOURNAMENT_ORGANIZATION_ROLES),
roleDisplayName: z.preprocess(
falsyToNull,
z.string().trim().max(32).nullable(),
),
}),
)
.max(32)
.refine(
(arr) =>
arr.map((x) => x.userId).length ===
new Set(arr.map((x) => x.userId)).size,
{
message: "Same member listed twice",
},
),
socials: z
.array(
z.object({
value: z.string().trim().url().max(100).optional().or(z.literal("")),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.value).length ===
new Set(arr.map((x) => x.value)).size,
{
message: "Duplicate social links",
},
),
series: z
.array(
z.object({
name: z.string().trim().min(1).max(32),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
),
showLeaderboard: z.boolean(),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.name).length === new Set(arr.map((x) => x.name)).size,
{
message: "Duplicate series",
},
),
badges: z.array(id).max(50),
});
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
export { action, handle, loader, meta };
type FormFields = z.infer<typeof organizationEditSchema> & {
members: Array<
@@ -149,7 +79,7 @@ export default function TournamentOrganizationEditPage() {
<TextAreaFormField<typeof organizationEditSchema>
label={t("common:forms.description")}
name="description"
maxLength={DESCRIPTION_MAX_LENGTH}
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
/>
<MembersFormField />
@@ -291,7 +221,7 @@ function SeriesFieldset({
<TextAreaFormField<FormFields>
label={t("common:forms.description")}
name={`series.${idx}.description` as const}
maxLength={DESCRIPTION_MAX_LENGTH}
maxLength={TOURNAMENT_ORGANIZATION.DESCRIPTION_MAX_LENGTH}
/>
<ToggleFormField<FormFields>

View File

@@ -1,2 +1,6 @@
export const TOURNAMENT_SERIES_EVENTS_PER_PAGE = 20;
export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;
export const TOURNAMENT_ORGANIZATION = {
DESCRIPTION_MAX_LENGTH: 1_000,
};

View File

@@ -0,0 +1,75 @@
import { z } from "zod";
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
import { mySlugify } from "~/utils/urls";
import { falsyToNull, id } from "~/utils/zod";
export const DESCRIPTION_MAX_LENGTH = 1_000;
export const organizationEditSchema = z.object({
name: z
.string()
.trim()
.min(2)
.max(32)
.refine((val) => mySlugify(val).length >= 2, {
message: "Not enough non-special characters",
}),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
),
members: z
.array(
z.object({
userId: z.number().int().positive(),
role: z.enum(TOURNAMENT_ORGANIZATION_ROLES),
roleDisplayName: z.preprocess(
falsyToNull,
z.string().trim().max(32).nullable(),
),
}),
)
.max(32)
.refine(
(arr) =>
arr.map((x) => x.userId).length ===
new Set(arr.map((x) => x.userId)).size,
{
message: "Same member listed twice",
},
),
socials: z
.array(
z.object({
value: z.string().trim().url().max(100).optional().or(z.literal("")),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.value).length ===
new Set(arr.map((x) => x.value)).size,
{
message: "Duplicate social links",
},
),
series: z
.array(
z.object({
name: z.string().trim().min(1).max(32),
description: z.preprocess(
falsyToNull,
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
),
showLeaderboard: z.boolean(),
}),
)
.max(10)
.refine(
(arr) =>
arr.map((x) => x.name).length === new Set(arr.map((x) => x.name)).size,
{
message: "Duplicate series",
},
),
badges: z.array(id).max(50),
});

View File

@@ -106,7 +106,9 @@
.org__calendar__container {
position: sticky;
top: 47px;
/* TODO: uncomment when top nav is sticky again */
/* top: 47px; */
top: 0;
}
}

View File

@@ -506,7 +506,7 @@ function RemoveStaffButton({
["userId", staff.id],
["_action", "REMOVE_STAFF"],
]}
deleteButtonText="Remove"
submitButtonText="Remove"
>
<Button
variant="minimal-destructive"

View File

@@ -263,7 +263,7 @@ function TournamentRegisterInfoTabs() {
<FormWithConfirm
dialogHeading={`Leave "${tournament.teamMemberOfByUser(user)?.name}"?`}
fields={[["_action", "LEAVE_TEAM"]]}
deleteButtonText="Leave"
submitButtonText="Leave"
>
<Button
className="build__small-text"
@@ -705,7 +705,7 @@ function TeamInfo({
) : canUnregister ? (
<FormWithConfirm
dialogHeading={t("tournament:pre.info.unregister.confirm")}
deleteButtonText={t("tournament:pre.info.unregister")}
submitButtonText={t("tournament:pre.info.unregister")}
fields={[["_action", "UNREGISTER"]]}
>
<Button

View File

@@ -9,10 +9,11 @@ import type {
TablesInsertable,
UserPreferences,
} from "~/db/tables";
import type { ChatUser } from "~/features/chat/components/Chat";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import type { CommonUser } from "~/utils/kysely.server";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server";
import { safeNumberParse } from "~/utils/number";
const identifierToUserIdQuery = (identifier: string) =>
@@ -325,6 +326,28 @@ export function findAllPlusServerMembers() {
.execute();
}
export async function findChatUsersByUserIds(userIds: number[]) {
const users = await db
.selectFrom("User")
.select([
"User.id",
"User.discordId",
"User.discordAvatar",
"User.username",
userChatNameColor,
])
.where("User.id", "in", userIds)
.execute();
const result: Record<number, ChatUser> = {};
for (const user of users) {
result[user.id] = user;
}
return result;
}
const withMaxEventStartTime = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
return eb
.selectFrom("CalendarEventDate")

View File

@@ -1,6 +1,5 @@
import { type LoaderFunctionArgs, redirect } from "@remix-run/node";
import type { TCountryCode } from "countries-list";
import { countries, getEmojiFlag } from "countries-list";
import { countries } from "countries-list";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { i18next } from "~/modules/i18n/i18next.server";
@@ -33,7 +32,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
countries: Object.entries(countries)
.map(([code, country]) => ({
code,
emoji: getEmojiFlag(code as TCountryCode),
name:
translatedCountry({
countryCode: code,

View File

@@ -10,6 +10,7 @@ import { WeaponImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { SendouSwitch } from "~/components/elements/Switch";
import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
@@ -210,22 +211,25 @@ function CountrySelect() {
const data = useLoaderData<typeof loader>();
return (
<div>
<label htmlFor="country">{t("user:country")}</label>
<select
className="u-edit__country-select"
name="country"
id="country"
defaultValue={data.user.country ?? ""}
>
<option value="" />
{data.countries.map((country) => (
<option key={country.code} value={country.code}>
{`${country.name} ${country.emoji}`}
</option>
))}
</select>
</div>
<SendouSelect
items={data.countries.map((country) => ({
...country,
id: country.code,
key: country.code,
}))}
label={t("user:country")}
search={{
placeholder: t("user:forms.country.search.placeholder"),
}}
name="country"
defaultSelectedKey={data.user.country ?? undefined}
>
{({ key, ...item }) => (
<SendouSelectItem key={key} {...item}>
{item.name}
</SendouSelectItem>
)}
</SendouSelect>
);
}

View File

@@ -3,9 +3,11 @@ import * as React from "react";
export function usePagination<T>({
items,
pageSize,
scrollToTop = true,
}: {
items: T[];
pageSize: number;
scrollToTop?: boolean;
}) {
const [currentPage, setCurrentPage] = React.useState(1);
const pagesCount = Math.ceil(items.length / pageSize);
@@ -18,25 +20,25 @@ export function usePagination<T>({
const nextPage = React.useCallback(() => {
if (currentPage < pagesCount) {
setCurrentPage((prev) => prev + 1);
window.scrollTo(0, 0);
if (scrollToTop) window.scrollTo(0, 0);
}
}, [currentPage, pagesCount]);
}, [currentPage, pagesCount, scrollToTop]);
const previousPage = React.useCallback(() => {
if (currentPage > 1) {
setCurrentPage((prev) => prev - 1);
window.scrollTo(0, 0);
if (scrollToTop) window.scrollTo(0, 0);
}
}, [currentPage]);
}, [currentPage, scrollToTop]);
const setPage = React.useCallback(
(page: number) => {
if (page > 0 && page <= pagesCount) {
setCurrentPage(page);
window.scrollTo(0, 0);
if (scrollToTop) window.scrollTo(0, 0);
}
},
[pagesCount],
[pagesCount, scrollToTop],
);
const thereIsNextPage = currentPage < pagesCount;

View File

@@ -12,6 +12,7 @@ import gearDa from "../../../locales/da/gear.json";
import lfgDa from "../../../locales/da/lfg.json";
import orgDa from "../../../locales/da/org.json";
import qDa from "../../../locales/da/q.json";
import scrimsDa from "../../../locales/da/scrims.json";
import teamDa from "../../../locales/da/team.json";
import tournamentDa from "../../../locales/da/tournament.json";
import userDa from "../../../locales/da/user.json";
@@ -31,6 +32,7 @@ import gearDe from "../../../locales/de/gear.json";
import lfgDe from "../../../locales/de/lfg.json";
import orgDe from "../../../locales/de/org.json";
import qDe from "../../../locales/de/q.json";
import scrimsDe from "../../../locales/de/scrims.json";
import teamDe from "../../../locales/de/team.json";
import tournamentDe from "../../../locales/de/tournament.json";
import userDe from "../../../locales/de/user.json";
@@ -50,6 +52,7 @@ import gear from "../../../locales/en/gear.json";
import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import q from "../../../locales/en/q.json";
import scrimsEn from "../../../locales/en/scrims.json";
import team from "../../../locales/en/team.json";
import tournament from "../../../locales/en/tournament.json";
import user from "../../../locales/en/user.json";
@@ -69,6 +72,7 @@ import gearEsEs from "../../../locales/es-ES/gear.json";
import lfgEsEs from "../../../locales/es-ES/lfg.json";
import orgEsEs from "../../../locales/es-ES/org.json";
import qEsEs from "../../../locales/es-ES/q.json";
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
import teamEsEs from "../../../locales/es-ES/team.json";
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
import userEsEs from "../../../locales/es-ES/user.json";
@@ -88,6 +92,7 @@ import gearEsUs from "../../../locales/es-US/gear.json";
import lfgEsUs from "../../../locales/es-US/lfg.json";
import orgEsUs from "../../../locales/es-US/org.json";
import qEsUs from "../../../locales/es-US/q.json";
import scrimsEsUs from "../../../locales/es-US/scrims.json";
import teamEsUs from "../../../locales/es-US/team.json";
import tournamentEsUs from "../../../locales/es-US/tournament.json";
import userEsUs from "../../../locales/es-US/user.json";
@@ -107,6 +112,7 @@ import gearFrCa from "../../../locales/fr-CA/gear.json";
import lfgFrCa from "../../../locales/fr-CA/lfg.json";
import orgFrCa from "../../../locales/fr-CA/org.json";
import qFrCa from "../../../locales/fr-CA/q.json";
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
import userFrCa from "../../../locales/fr-CA/user.json";
@@ -126,6 +132,7 @@ import gearFrEu from "../../../locales/fr-EU/gear.json";
import lfgFrEu from "../../../locales/fr-EU/lfg.json";
import orgFrEu from "../../../locales/fr-EU/org.json";
import qFrEu from "../../../locales/fr-EU/q.json";
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
import userFrEu from "../../../locales/fr-EU/user.json";
@@ -145,6 +152,7 @@ import gearHe from "../../../locales/he/gear.json";
import lfgHe from "../../../locales/he/lfg.json";
import orgHe from "../../../locales/he/org.json";
import qHe from "../../../locales/he/q.json";
import scrimsHe from "../../../locales/he/scrims.json";
import teamHe from "../../../locales/he/team.json";
import tournamentHe from "../../../locales/he/tournament.json";
import userHe from "../../../locales/he/user.json";
@@ -164,6 +172,7 @@ import gearIt from "../../../locales/it/gear.json";
import lfgIt from "../../../locales/it/lfg.json";
import orgIt from "../../../locales/it/org.json";
import qIt from "../../../locales/it/q.json";
import scrimsIt from "../../../locales/it/scrims.json";
import teamIt from "../../../locales/it/team.json";
import tournamentIt from "../../../locales/it/tournament.json";
import userIt from "../../../locales/it/user.json";
@@ -183,6 +192,7 @@ import gearJa from "../../../locales/ja/gear.json";
import lfgJa from "../../../locales/ja/lfg.json";
import orgJa from "../../../locales/ja/org.json";
import qJa from "../../../locales/ja/q.json";
import scrimsJa from "../../../locales/ja/scrims.json";
import teamJa from "../../../locales/ja/team.json";
import tournamentJa from "../../../locales/ja/tournament.json";
import userJa from "../../../locales/ja/user.json";
@@ -202,6 +212,7 @@ import gearKo from "../../../locales/ko/gear.json";
import lfgKo from "../../../locales/ko/lfg.json";
import orgKo from "../../../locales/ko/org.json";
import qKo from "../../../locales/ko/q.json";
import scrimsKo from "../../../locales/ko/scrims.json";
import teamKo from "../../../locales/ko/team.json";
import tournamentKo from "../../../locales/ko/tournament.json";
import userKo from "../../../locales/ko/user.json";
@@ -221,6 +232,7 @@ import gearNl from "../../../locales/nl/gear.json";
import lfgNl from "../../../locales/nl/lfg.json";
import orgNl from "../../../locales/nl/org.json";
import qNl from "../../../locales/nl/q.json";
import scrimsNl from "../../../locales/nl/scrims.json";
import teamNl from "../../../locales/nl/team.json";
import tournamentNl from "../../../locales/nl/tournament.json";
import userNl from "../../../locales/nl/user.json";
@@ -240,6 +252,7 @@ import gearPl from "../../../locales/pl/gear.json";
import lfgPl from "../../../locales/pl/lfg.json";
import orgPl from "../../../locales/pl/org.json";
import qPl from "../../../locales/pl/q.json";
import scrimsPl from "../../../locales/pl/scrims.json";
import teamPl from "../../../locales/pl/team.json";
import tournamentPl from "../../../locales/pl/tournament.json";
import userPl from "../../../locales/pl/user.json";
@@ -259,6 +272,7 @@ import gearPtBr from "../../../locales/pt-BR/gear.json";
import lfgPtBr from "../../../locales/pt-BR/lfg.json";
import orgPtBr from "../../../locales/pt-BR/org.json";
import qPtBr from "../../../locales/pt-BR/q.json";
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
import userPtBr from "../../../locales/pt-BR/user.json";
@@ -278,6 +292,7 @@ import gearRu from "../../../locales/ru/gear.json";
import lfgRu from "../../../locales/ru/lfg.json";
import orgRu from "../../../locales/ru/org.json";
import qRu from "../../../locales/ru/q.json";
import scrimsRu from "../../../locales/ru/scrims.json";
import teamRu from "../../../locales/ru/team.json";
import tournamentRu from "../../../locales/ru/tournament.json";
import userRu from "../../../locales/ru/user.json";
@@ -297,6 +312,7 @@ import gearZh from "../../../locales/zh/gear.json";
import lfgZh from "../../../locales/zh/lfg.json";
import orgZh from "../../../locales/zh/org.json";
import qZh from "../../../locales/zh/q.json";
import scrimsZh from "../../../locales/zh/scrims.json";
import teamZh from "../../../locales/zh/team.json";
import tournamentZh from "../../../locales/zh/tournament.json";
import userZh from "../../../locales/zh/user.json";
@@ -308,6 +324,7 @@ export const resources = {
gear: gearEsUs,
faq: faqEsUs,
weapons: weaponsEsUs,
scrims: scrimsEsUs,
common: commonEsUs,
"game-misc": gameMiscEsUs,
tournament: tournamentEsUs,
@@ -329,6 +346,7 @@ export const resources = {
gear: gear,
faq: faq,
weapons: weapons,
scrims: scrimsEn,
common: common,
"game-misc": gameMisc,
tournament: tournament,
@@ -350,6 +368,7 @@ export const resources = {
gear: gearKo,
faq: faqKo,
weapons: weaponsKo,
scrims: scrimsKo,
common: commonKo,
"game-misc": gameMiscKo,
tournament: tournamentKo,
@@ -371,6 +390,7 @@ export const resources = {
gear: gearDe,
faq: faqDe,
weapons: weaponsDe,
scrims: scrimsDe,
common: commonDe,
"game-misc": gameMiscDe,
tournament: tournamentDe,
@@ -392,6 +412,7 @@ export const resources = {
gear: gearNl,
faq: faqNl,
weapons: weaponsNl,
scrims: scrimsNl,
common: commonNl,
"game-misc": gameMiscNl,
tournament: tournamentNl,
@@ -413,6 +434,7 @@ export const resources = {
gear: gearPtBr,
faq: faqPtBr,
weapons: weaponsPtBr,
scrims: scrimsPtBr,
common: commonPtBr,
"game-misc": gameMiscPtBr,
tournament: tournamentPtBr,
@@ -434,6 +456,7 @@ export const resources = {
gear: gearZh,
faq: faqZh,
weapons: weaponsZh,
scrims: scrimsZh,
common: commonZh,
"game-misc": gameMiscZh,
tournament: tournamentZh,
@@ -455,6 +478,7 @@ export const resources = {
gear: gearFrCa,
faq: faqFrCa,
weapons: weaponsFrCa,
scrims: scrimsFrCa,
common: commonFrCa,
"game-misc": gameMiscFrCa,
tournament: tournamentFrCa,
@@ -476,6 +500,7 @@ export const resources = {
gear: gearRu,
faq: faqRu,
weapons: weaponsRu,
scrims: scrimsRu,
common: commonRu,
"game-misc": gameMiscRu,
tournament: tournamentRu,
@@ -497,6 +522,7 @@ export const resources = {
gear: gearIt,
faq: faqIt,
weapons: weaponsIt,
scrims: scrimsIt,
common: commonIt,
"game-misc": gameMiscIt,
tournament: tournamentIt,
@@ -518,6 +544,7 @@ export const resources = {
gear: gearJa,
faq: faqJa,
weapons: weaponsJa,
scrims: scrimsJa,
common: commonJa,
"game-misc": gameMiscJa,
tournament: tournamentJa,
@@ -539,6 +566,7 @@ export const resources = {
gear: gearDa,
faq: faqDa,
weapons: weaponsDa,
scrims: scrimsDa,
common: commonDa,
"game-misc": gameMiscDa,
tournament: tournamentDa,
@@ -560,6 +588,7 @@ export const resources = {
gear: gearEsEs,
faq: faqEsEs,
weapons: weaponsEsEs,
scrims: scrimsEsEs,
common: commonEsEs,
"game-misc": gameMiscEsEs,
tournament: tournamentEsEs,
@@ -581,6 +610,7 @@ export const resources = {
gear: gearHe,
faq: faqHe,
weapons: weaponsHe,
scrims: scrimsHe,
common: commonHe,
"game-misc": gameMiscHe,
tournament: tournamentHe,
@@ -602,6 +632,7 @@ export const resources = {
gear: gearFrEu,
faq: faqFrEu,
weapons: weaponsFrEu,
scrims: scrimsFrEu,
common: commonFrEu,
"game-misc": gameMiscFrEu,
tournament: tournamentFrEu,
@@ -623,6 +654,7 @@ export const resources = {
gear: gearPl,
faq: faqPl,
weapons: weaponsPl,
scrims: scrimsPl,
common: commonPl,
"game-misc": gameMiscPl,
tournament: tournamentPl,

View File

@@ -0,0 +1,27 @@
import type { EntityWithPermissions } from "~/modules/permissions/types";
import { isAdmin } from "~/permissions";
// TODO: could avoid passing user in after remix middlewares land with async context
/**
* Checks if a user has the required permission to perform an action on a given entity.
*
* @throws {Response} - Throws a 403 Forbidden response if the user does not have the required permission.
*/
export function requirePermission<
T extends EntityWithPermissions,
K extends keyof T["permissions"],
>(obj: T, permission: K, user: { id: number }) {
// admin can do anything in production but not in development for better testing
if (process.env.NODE_ENV === "production" && isAdmin(user)) {
return;
}
const permissions = obj.permissions as Record<K, number[]>;
if (permissions[permission].includes(user.id)) {
return;
}
throw new Response("Forbidden", { status: 403 });
}

View File

@@ -0,0 +1,5 @@
export type Permissions = Record<string, number[]>;
export type EntityWithPermissions = {
permissions: Permissions;
};

View File

@@ -0,0 +1,24 @@
import { useUser } from "~/features/auth/core/user";
import type { EntityWithPermissions } from "~/modules/permissions/types";
import { isAdmin } from "~/permissions";
/**
* Determines whether a user has a specific permission for a given entity.
*
* @returns A boolean indicating whether the user has the specified permission.
*/
export function useHasPermission<
T extends EntityWithPermissions,
K extends keyof T["permissions"],
>(obj: T, permission: K) {
const user = useUser();
if (!user) return false;
// admin can do anything in production but not in development for better testing
if (process.env.NODE_ENV === "production" && isAdmin(user)) {
return true;
}
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
}

View File

@@ -8,7 +8,7 @@ import type { FindMatchById } from "./features/tournament-bracket/queries/findMa
import { allTruthy } from "./utils/arrays";
import { databaseTimestampToDate } from "./utils/dates";
// TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness
// TODO: move to permissions module and generalize a lot of the logic
type IsAdminUser = Pick<Tables["User"], "id">;
export function isAdmin(user?: IsAdminUser) {

View File

@@ -251,6 +251,7 @@ export const namespaceJsonsToPreloadObj: Record<Namespace, boolean> = {
gear: true,
user: true,
weapons: true,
scrims: true,
tournament: true,
team: true,
vods: true,

View File

@@ -178,6 +178,19 @@ export default [
route("new", "features/lfg/routes/lfg.new.tsx"),
]),
...prefix("/scrims", [
index("features/scrims/routes/scrims.tsx"),
route("new", "features/scrims/routes/scrims.new.tsx"),
route(":id", "features/scrims/routes/scrims.$id.tsx"),
]),
route("/associations", "features/associations/routes/associations.tsx", [
route(
"/associations/new",
"features/associations/routes/associations.new.tsx",
),
]),
route("/admin", "features/admin/routes/admin.tsx"),
...prefix("/a", [

View File

@@ -322,8 +322,8 @@ select:focus {
.my-table {
width: 100%;
border-collapse: collapse;
border-spacing: 0 var(--s-1-5);
border-collapse: separate;
border-spacing: 0;
font-size: var(--fonts-xs);
text-align: left;
border-color: var(--border);
@@ -339,12 +339,6 @@ select:focus {
font-size: var(--fonts-xxs);
}
.my-table tr {
border-style: solid;
border-width: 0px;
border-bottom-width: 1px;
}
.my-table tbody tr:hover {
background-color: var(--bg-lighter);
}
@@ -357,6 +351,14 @@ select:focus {
padding: var(--s-2) var(--s-2-5);
}
table tr:first-child td {
border-top: 1px solid var(--border);
}
table td {
border-bottom: 1px solid var(--border);
}
td > input[type="checkbox"] {
vertical-align: middle;
}
@@ -529,8 +531,10 @@ dialog::backdrop {
.tab__buttons-container svg {
--icon-size: 16px;
width: var(--icon-size);
height: var(--icon-size);
min-width: var(--icon-size);
min-height: var(--icon-size);
max-width: var(--icon-size);
max-height: var(--icon-size);
margin-inline-end: var(--s-1-5);
}
@@ -553,7 +557,9 @@ dialog::backdrop {
.tab__buttons-container__sticky {
position: sticky;
top: 47px;
/* TODO: uncomment when top nav is sticky again */
/* top: 47px; */
top: 0;
z-index: 1;
background-color: var(--bg);
}
@@ -1818,51 +1824,6 @@ html[dir="rtl"] .fix-rtl {
display: none !important;
}
.badge-display__badges {
display: flex;
min-width: 20rem;
max-width: 24rem;
align-items: center;
padding: var(--s-2);
border-radius: var(--rounded);
background-color: var(--bg-badge);
margin-inline: auto;
}
.badge-display__small-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
cursor: pointer;
gap: var(--s-3);
}
.badge-display__badge-explanation {
color: var(--text-lighter);
font-size: var(--fonts-xs);
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.badge-display__small-badge-container {
position: relative;
}
.badge-display__small-badge-count {
position: absolute;
top: 0;
right: 0;
margin-top: -8px;
margin-right: auto;
margin-left: auto;
color: var(--theme-vibrant);
font-size: var(--fonts-xxxs);
font-weight: var(--bold);
}
.format-selector__count {
color: var(--theme);
font-size: var(--fonts-sm);

View File

@@ -1,127 +1,3 @@
/* contains styles for react aria components */
.react-aria-Button {
display: flex;
width: auto;
align-items: center;
justify-content: center;
border: 2px solid var(--theme);
border-radius: var(--rounded-sm);
appearance: none;
background: var(--theme);
color: var(--button-text);
cursor: pointer;
font-size: var(--fonts-sm);
font-weight: var(--bold);
line-height: 1.2;
outline-offset: 2px;
padding-block: var(--s-1-5);
padding-inline: var(--s-2-5);
user-select: none;
}
.react-aria-Button[data-focus-visible] {
outline: 2px solid var(--theme);
}
.react-aria-Button[data-pressed] {
transform: translateY(1px);
}
.react-aria-Button[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
transform: initial;
}
.react-aria-Button.outlined {
background-color: var(--theme-very-transparent);
color: var(--theme);
}
.react-aria-Button.outlined-success {
border-color: var(--theme-success);
background-color: transparent;
color: var(--theme-success);
}
.react-aria-Button.small {
font-size: var(--fonts-xs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
.react-aria-Button.miniscule {
font-size: var(--fonts-xxs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
.react-aria-Button.big {
font-size: var(--fonts-md);
padding-block: var(--s-2-5);
padding-inline: var(--s-6);
}
.react-aria-Button.minimal {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme);
outline: initial;
}
.react-aria-Button.minimal[data-focus-visible] {
outline: 2px solid var(--theme);
}
.react-aria-Button.minimal-success {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme-success);
}
.react-aria-Button.success {
border-color: var(--theme-success);
background-color: var(--theme-success);
outline-color: var(--theme-success);
}
.react-aria-Button.destructive {
border-color: var(--theme-error);
background-color: transparent;
color: var(--theme-error);
outline-color: var(--theme-error);
}
.react-aria-Button.minimal-destructive {
padding: 0;
border: none;
background-color: transparent;
color: var(--theme-error);
outline-color: var(--theme-error);
}
.sendou-button-icon {
width: 1.25rem;
margin-inline-end: var(--s-1-5);
}
.sendou-button-icon.lonely {
margin-inline-end: 0 !important;
}
.react-aria-Button.small > .sendou-button-icon {
width: 1rem;
margin-inline-end: var(--s-1);
}
.react-aria-Button.miniscule > .sendou-button-icon {
width: 0.857rem;
margin-inline-end: var(--s-1);
}
.sendou-popover-content {
max-width: 20rem;
padding: var(--s-2);

View File

@@ -102,6 +102,10 @@
fill: var(--theme-info);
}
.bg {
background-color: var(--bg);
}
.bg-transparent-important {
background-color: transparent !important;
}
@@ -162,6 +166,10 @@
width: var(--s-24);
}
.min-w-max {
min-width: max-content;
}
.w-max {
width: max-content;
}

View File

@@ -28,9 +28,9 @@ html {
--theme-warning: #c9c900;
--theme-warning-transparent: #c9c90052;
--theme-success: #00a514;
--theme-success-transparent: #00a51452;
--theme-success-transparent: #badfb7;
--theme-info: #1fb0d0;
--theme-info-transparent: #1fb0d052;
--theme-info-transparent: #b3d8e7;
--theme-informative-yellow: #b09901;
--theme-informative-red: #9d0404;
--theme-informative-blue: #007f9c;
@@ -98,6 +98,7 @@ html {
--input-width-extra-small: 10rem;
--input-width-small: 12rem;
--input-width-medium: 18rem;
--select-width: 225px;
}
html.dark {
@@ -129,9 +130,9 @@ html.dark {
--theme-error-semi-transparent: rgba(199 13 6 / 70%);
--theme-warning: #f5f587;
--theme-success: #a3ffae;
--theme-success-transparent: #a3ffae52;
--theme-success-transparent: #36534d;
--theme-info: #87cddc;
--theme-info-transparent: #87cddc52;
--theme-info-transparent: #2c435b;
--theme-informative-yellow: #ffed75;
--theme-informative-red: #ff9494;
--theme-informative-blue: #a7efff;

View File

@@ -89,7 +89,7 @@ export function wrappedLoader<T>({
}
/**
* Asserts that the given response errored out (with a toast message, via `validate(cond)` call)
* Asserts that the given response errored out (with a toast message, via `errorToastIfFalsy(cond)` call)
*/
export function assertResponseErrored(response: Response) {
expect(response.headers.get("Location")).toContain("?__error=");

View File

@@ -1,6 +1,7 @@
import { type Locator, type Page, expect } from "@playwright/test";
import { ADMIN_ID } from "~/constants";
import type { SeedVariation } from "~/features/api-private/routes/seed";
import { tournamentBracketsPage } from "./urls";
export async function selectWeapon({
page,
@@ -90,3 +91,16 @@ export async function fetchSendouInk<T>(url: string) {
return res.json() as T;
}
export const startBracket = async (page: Page, tournamentId = 2) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();
await page.getByTestId("confirm-finalize-bracket-button").click();
};

View File

@@ -353,6 +353,26 @@ export const sendouQMatchPage = (id: Tables["GroupMatch"]["id"]) => {
return `${SENDOUQ_PAGE}/match/${id}`;
};
export const scrimsPage = () => {
return "/scrims";
};
export const scrimPage = (id: number) => {
return `${scrimsPage()}/${id}`;
};
export const newScrimPostPage = () => {
return "/scrims/new";
};
export const associationsPage = (inviteCode?: string) => {
return `/associations${inviteCode ? `?inviteCode=${inviteCode}` : ""}`;
};
export const newAssociationsPage = () => {
return "/associations/new";
};
export const getWeaponUsage = ({
userId,
season,

View File

@@ -1,6 +1,6 @@
import type { ZodType } from "zod";
import { z } from "zod";
import { CUSTOM_CSS_VAR_COLORS } from "~/constants";
import { CUSTOM_CSS_VAR_COLORS, INVITE_CODE_LENGTH } from "~/constants";
import type { abilitiesShort } from "~/modules/in-game-lists";
import { abilities, mainWeaponIds, stageIds } from "~/modules/in-game-lists";
import { FRIEND_CODE_REGEXP } from "../features/sendouq/q-constants";
@@ -13,6 +13,11 @@ export const idObject = z.object({
});
export const optionalId = z.coerce.number().int().positive().optional();
export const inviteCode = z.string().length(INVITE_CODE_LENGTH);
export const inviteCodeObject = z.object({
inviteCode,
});
export const nonEmptyString = z.string().trim().min(1, {
message: "Required",
});
@@ -249,6 +254,12 @@ export function noDuplicates(arr: (number | string)[]) {
return new Set(arr).size === arr.length;
}
export function filterOutNullishMembers(value: unknown) {
if (!Array.isArray(value)) return value;
return value.filter((member) => member !== null && member !== undefined);
}
export function removeDuplicates(value: unknown) {
if (!Array.isArray(value)) return value;

76
e2e/associations.spec.ts Normal file
View File

@@ -0,0 +1,76 @@
import test, { expect } from "@playwright/test";
import { ADMIN_ID } from "~/constants";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import {
impersonate,
isNotVisible,
navigate,
seed,
submit,
} from "~/utils/playwright";
import { associationsPage, scrimsPage } from "~/utils/urls";
test.describe("Associations", () => {
test("creates a new association", async ({ page }) => {
await seed(page);
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: "/",
});
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-association").click();
await page.getByLabel("Name").fill("My Association");
await submit(page);
await expect(
page.getByRole("heading").filter({ hasText: "My Association" }),
).toBeVisible();
});
test("deletes an association", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
await page.getByRole("link", { name: "Associations" }).click();
await expect(page.getByTestId("delete-association")).toHaveCount(2);
await page.getByTestId("delete-association").first().click();
await page.getByTestId("confirm-button").click();
await expect(page.getByTestId("delete-association")).toHaveCount(1);
});
test("joins and leaves an association", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: associationsPage(),
});
const inviteLink = await page
.getByLabel("Share link to add members")
.first()
.inputValue();
await impersonate(page, NZAP_TEST_ID);
await navigate({
page,
url: inviteLink.replace("https://sendou.ink", "http://localhost:5173"),
});
await submit(page);
await page.getByTestId("leave-team-button").click();
await page.getByTestId("confirm-button").click();
await isNotVisible(page.getByTestId("leave-team-button"));
});
});

95
e2e/scrims.spec.ts Normal file
View File

@@ -0,0 +1,95 @@
import test, { expect } from "@playwright/test";
import { ADMIN_ID } from "~/constants";
import {
impersonate,
navigate,
seed,
selectUser,
submit,
} from "~/utils/playwright";
import { scrimsPage } from "~/utils/urls";
test.describe("Scrims", () => {
test("creates a new scrim & deletes it", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: "/",
});
await page.getByTestId("anything-adder-menu-button").click();
await page.getByTestId("menu-item-scrimPost").click();
await page.getByLabel("With").selectOption("PICKUP");
await selectUser({
labelName: "User 2",
page,
userName: "N-ZAP",
});
await selectUser({
labelName: "User 3",
page,
userName: "ab",
});
await selectUser({
labelName: "User 4",
page,
userName: "de",
});
await page.getByLabel("Visibility").selectOption("2");
await page.getByLabel("Text").fill("Test scrim");
await submit(page);
await expect(page.getByTestId("limited-visibility-popover")).toBeVisible();
await page.getByRole("button", { name: "Delete" }).first().click();
await page.getByTestId("confirm-button").click();
await expect(page.getByRole("button", { name: "Delete" })).toHaveCount(1);
});
test("requests an existing scrim post & cancels the request", async ({
page,
}) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
await page.getByTestId("tab-Available").click();
await page.getByRole("button", { name: "Request" }).first().click();
await submit(page);
await page.getByTestId("tab-Requests").click();
const cancelRequestButton = page.getByRole("button", {
name: "Cancel",
});
expect(cancelRequestButton).toHaveCount(5);
await cancelRequestButton.first().click();
await page.getByTestId("confirm-button").click();
await expect(cancelRequestButton).toHaveCount(4);
});
test("accepts a request", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({
page,
url: scrimsPage(),
});
await page.getByRole("button", { name: "Accept" }).first().click();
await page.getByTestId("confirm-button").click();
await page.getByRole("link", { name: "Contact" }).click();
await expect(page.getByText("Scheduled scrim")).toBeVisible();
});
});

View File

@@ -1,16 +0,0 @@
import type { Page } from "@playwright/test";
import { impersonate, navigate, seed } from "~/utils/playwright";
import { tournamentBracketsPage } from "~/utils/urls";
export const startBracket = async (page: Page, tournamentId = 2) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: tournamentBracketsPage({ tournamentId }),
});
await page.getByTestId("finalize-bracket-button").click();
await page.getByTestId("confirm-finalize-bracket-button").click();
};

View File

@@ -7,6 +7,7 @@ import {
navigate,
seed,
selectUser,
startBracket,
submit,
} from "~/utils/playwright";
import {
@@ -17,7 +18,6 @@ import {
tournamentRegisterPage,
userResultsPage,
} from "~/utils/urls";
import { startBracket } from "./shared";
const navigateToMatch = async (page: Page, matchId: number) => {
await expect(async () => {

View File

@@ -8,6 +8,7 @@ import {
navigate,
seed,
selectUser,
startBracket,
submit,
} from "~/utils/playwright";
import {
@@ -15,7 +16,6 @@ import {
tournamentBracketsPage,
tournamentMatchPage,
} from "~/utils/urls";
import { startBracket } from "./shared";
const TOURNAMENT_ID = 2;

Some files were not shown because too many files have changed in this diff Show More