mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Scrims (#2211)
* 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:
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?:
|
||||
| "primary"
|
||||
| "success"
|
||||
| "outlined"
|
||||
| "outlined-success"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
121
app/components/elements/Button.module.css
Normal file
121
app/components/elements/Button.module.css
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
132
app/components/elements/Select.module.css
Normal file
132
app/components/elements/Select.module.css
Normal 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);
|
||||
}
|
||||
114
app/components/elements/Select.tsx
Normal file
114
app/components/elements/Select.tsx
Normal 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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
app/components/form/DateTimeFormField.tsx
Normal file
33
app/components/form/DateTimeFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
17
app/components/icons/ArrowDownOnSquare.tsx
Normal file
17
app/components/icons/ArrowDownOnSquare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/icons/ArrowUpOnSquare.tsx
Normal file
17
app/components/icons/ArrowUpOnSquare.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/ChevronUpDown.tsx
Normal file
18
app/components/icons/ChevronUpDown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
app/components/icons/MegaphoneIcon.tsx
Normal file
13
app/components/icons/MegaphoneIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/icons/SpeechBubbleFilled.tsx
Normal file
17
app/components/icons/SpeechBubbleFilled.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
186
app/features/associations/AssociationRepository.server.ts
Normal file
186
app/features/associations/AssociationRepository.server.ts
Normal 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();
|
||||
}
|
||||
37
app/features/associations/actions/associations.new.server.ts
Normal file
37
app/features/associations/actions/associations.new.server.ts
Normal 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());
|
||||
};
|
||||
131
app/features/associations/actions/associations.server.ts
Normal file
131
app/features/associations/actions/associations.server.ts
Normal 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);
|
||||
}
|
||||
12
app/features/associations/associations-constants.ts
Normal file
12
app/features/associations/associations-constants.ts
Normal 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;
|
||||
51
app/features/associations/associations-schemas.ts
Normal file
51
app/features/associations/associations-schemas.ts
Normal 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
|
||||
]);
|
||||
12
app/features/associations/associations-types.ts
Normal file
12
app/features/associations/associations-types.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
121
app/features/associations/core/Association.test.ts
Normal file
121
app/features/associations/core/Association.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
49
app/features/associations/core/Association.ts
Normal file
49
app/features/associations/core/Association.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
61
app/features/associations/loaders/associations.server.ts
Normal file
61
app/features/associations/loaders/associations.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
app/features/associations/routes/associations.new.tsx
Normal file
39
app/features/associations/routes/associations.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
255
app/features/associations/routes/associations.tsx
Normal file
255
app/features/associations/routes/associations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
app/features/badges/components/BadgeDisplay.module.css
Normal file
73
app/features/badges/components/BadgeDisplay.module.css
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
> & {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
271
app/features/scrims/ScrimPostRepository.server.ts
Normal file
271
app/features/scrims/ScrimPostRepository.server.ts
Normal 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();
|
||||
}
|
||||
154
app/features/scrims/actions/scrims.new.server.ts
Normal file
154
app/features/scrims/actions/scrims.new.server.ts
Normal 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.",
|
||||
};
|
||||
}
|
||||
146
app/features/scrims/actions/scrims.server.ts
Normal file
146
app/features/scrims/actions/scrims.server.ts
Normal 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 };
|
||||
}
|
||||
112
app/features/scrims/components/WithFormField.tsx
Normal file
112
app/features/scrims/components/WithFormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/features/scrims/core/Scrim.ts
Normal file
19
app/features/scrims/core/Scrim.ts
Normal 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}`;
|
||||
}
|
||||
33
app/features/scrims/loaders/scrims.$id.server.ts
Normal file
33
app/features/scrims/loaders/scrims.$id.server.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
};
|
||||
20
app/features/scrims/loaders/scrims.new.server.ts
Normal file
20
app/features/scrims/loaders/scrims.new.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
45
app/features/scrims/loaders/scrims.server.ts
Normal file
45
app/features/scrims/loaders/scrims.server.ts
Normal 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) : [],
|
||||
};
|
||||
};
|
||||
50
app/features/scrims/routes/scrims.$id.module.css
Normal file
50
app/features/scrims/routes/scrims.$id.module.css
Normal 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);
|
||||
}
|
||||
146
app/features/scrims/routes/scrims.$id.tsx
Normal file
146
app/features/scrims/routes/scrims.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
app/features/scrims/routes/scrims.module.css
Normal file
59
app/features/scrims/routes/scrims.module.css
Normal 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;
|
||||
}
|
||||
273
app/features/scrims/routes/scrims.new.tsx
Normal file
273
app/features/scrims/routes/scrims.new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
668
app/features/scrims/routes/scrims.tsx
Normal file
668
app/features/scrims/routes/scrims.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/features/scrims/scrims-constants.ts
Normal file
20
app/features/scrims/scrims-constants.ts
Normal 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";
|
||||
180
app/features/scrims/scrims-schemas.ts
Normal file
180
app/features/scrims/scrims-schemas.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
49
app/features/scrims/scrims-types.ts
Normal file
49
app/features/scrims/scrims-types.ts
Normal 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;
|
||||
}
|
||||
55
app/features/scrims/scrims-utils.ts
Normal file
55
app/features/scrims/scrims-utils.ts
Normal 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);
|
||||
};
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -611,7 +611,7 @@ function TrustedUsers() {
|
||||
["_action", "REMOVE_TRUST"],
|
||||
["userToRemoveTrustFromId", trustedUser.id],
|
||||
]}
|
||||
deleteButtonText="Remove"
|
||||
submitButtonText="Remove"
|
||||
>
|
||||
<Button
|
||||
className="build__small-text"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -106,7 +106,9 @@
|
||||
|
||||
.org__calendar__container {
|
||||
position: sticky;
|
||||
top: 47px;
|
||||
/* TODO: uncomment when top nav is sticky again */
|
||||
/* top: 47px; */
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -506,7 +506,7 @@ function RemoveStaffButton({
|
||||
["userId", staff.id],
|
||||
["_action", "REMOVE_STAFF"],
|
||||
]}
|
||||
deleteButtonText="Remove"
|
||||
submitButtonText="Remove"
|
||||
>
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
app/modules/permissions/requirePermission.server.ts
Normal file
27
app/modules/permissions/requirePermission.server.ts
Normal 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 });
|
||||
}
|
||||
5
app/modules/permissions/types.ts
Normal file
5
app/modules/permissions/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type Permissions = Record<string, number[]>;
|
||||
|
||||
export type EntityWithPermissions = {
|
||||
permissions: Permissions;
|
||||
};
|
||||
24
app/modules/permissions/useHasPermission.ts
Normal file
24
app/modules/permissions/useHasPermission.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -251,6 +251,7 @@ export const namespaceJsonsToPreloadObj: Record<Namespace, boolean> = {
|
||||
gear: true,
|
||||
user: true,
|
||||
weapons: true,
|
||||
scrims: true,
|
||||
tournament: true,
|
||||
team: true,
|
||||
vods: true,
|
||||
|
||||
@@ -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", [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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=");
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
76
e2e/associations.spec.ts
Normal 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
95
e2e/scrims.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user