mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
New user search & dialog (#2270)
* From scrims * wip * wip * wip * wip * WIP * wip * wip * wip * wip * wip * import ordering
This commit is contained in:
parent
f3e4ea2115
commit
4d730e5d8b
|
|
@ -1,114 +0,0 @@
|
|||
import React from "react";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
// TODO: use react aria components
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
isOpen,
|
||||
close,
|
||||
className,
|
||||
closeOnAnyClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
className?: string;
|
||||
closeOnAnyClick?: boolean;
|
||||
}) {
|
||||
const ref = useDOMSync(isOpen);
|
||||
useControlledEsc({ ref, isOpen, close });
|
||||
|
||||
// https://stackoverflow.com/a/26984690
|
||||
const closeOnOutsideClick = close
|
||||
? (event: React.MouseEvent<HTMLDialogElement, MouseEvent>) => {
|
||||
if (closeOnAnyClick) return close();
|
||||
const rect: DOMRect = ref.current.getBoundingClientRect();
|
||||
|
||||
// https://stackoverflow.com/a/77402711
|
||||
const isFirefoxSelectClick = event.clientY === 0 && event.clientX === 0;
|
||||
if (isFirefoxSelectClick) return;
|
||||
|
||||
const isInDialog =
|
||||
rect.top <= event.clientY &&
|
||||
event.clientY <= rect.top + rect.height &&
|
||||
rect.left <= event.clientX &&
|
||||
event.clientX <= rect.left + rect.width;
|
||||
if (!isInDialog) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<dialog className={className} ref={ref} onClick={closeOnOutsideClick}>
|
||||
{children}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useDOMSync(isOpen: boolean) {
|
||||
const ref = React.useRef<any>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
|
||||
if (dialog.open && isOpen) return;
|
||||
if (!dialog.open && !isOpen) return;
|
||||
|
||||
const html = document.getElementsByTagName("html")[0];
|
||||
invariant(html);
|
||||
|
||||
if (isOpen) {
|
||||
dialog.showModal();
|
||||
html.classList.add("lock-scroll");
|
||||
} else {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
}
|
||||
|
||||
return () => {
|
||||
dialog.close();
|
||||
html.classList.remove("lock-scroll");
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
function useControlledEsc({
|
||||
ref,
|
||||
isOpen,
|
||||
close,
|
||||
}: {
|
||||
ref: React.MutableRefObject<any>;
|
||||
isOpen: boolean;
|
||||
close?: () => void;
|
||||
}) {
|
||||
React.useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return;
|
||||
|
||||
const preventDefault = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
dialog.addEventListener("cancel", preventDefault);
|
||||
|
||||
return () => {
|
||||
dialog.removeEventListener("cancel", preventDefault);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !close) return;
|
||||
|
||||
const closeOnEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", closeOnEsc);
|
||||
return () => document.removeEventListener("keydown", closeOnEsc);
|
||||
}, [isOpen, close]);
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@ import { type FetcherWithComponents, useFetcher } from "@remix-run/react";
|
|||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { Dialog } from "./Dialog";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
|
||||
export function FormWithConfirm({
|
||||
|
|
@ -13,11 +13,9 @@ export function FormWithConfirm({
|
|||
children,
|
||||
dialogHeading,
|
||||
submitButtonText,
|
||||
cancelButtonText,
|
||||
action,
|
||||
submitButtonTestId = "submit-button",
|
||||
submitButtonVariant = "destructive",
|
||||
cancelButtonVariant,
|
||||
fetcher: _fetcher,
|
||||
}: {
|
||||
fields?: (
|
||||
|
|
@ -27,11 +25,9 @@ export function FormWithConfirm({
|
|||
children: React.ReactNode;
|
||||
dialogHeading: string;
|
||||
submitButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
action?: string;
|
||||
submitButtonTestId?: string;
|
||||
submitButtonVariant?: ButtonProps["variant"];
|
||||
cancelButtonVariant?: ButtonProps["variant"];
|
||||
fetcher?: FetcherWithComponents<any>;
|
||||
}) {
|
||||
const componentsFetcher = useFetcher();
|
||||
|
|
@ -73,10 +69,15 @@ export function FormWithConfirm({
|
|||
document.body,
|
||||
)
|
||||
: null}
|
||||
<Dialog isOpen={dialogOpen} close={closeDialog} className="text-center">
|
||||
<SendouDialog
|
||||
isOpen={dialogOpen}
|
||||
onClose={closeDialog}
|
||||
onOpenChange={closeDialog}
|
||||
isDismissable
|
||||
>
|
||||
<div className="stack md">
|
||||
<h2 className="text-sm">{dialogHeading}</h2>
|
||||
<div className="stack horizontal md justify-center">
|
||||
<h2 className="text-md text-center">{dialogHeading}</h2>
|
||||
<div className="stack horizontal md justify-center mt-2">
|
||||
<SubmitButton
|
||||
form={id}
|
||||
variant={submitButtonVariant}
|
||||
|
|
@ -84,12 +85,9 @@ export function FormWithConfirm({
|
|||
>
|
||||
{submitButtonText ?? t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
<Button onClick={closeDialog} variant={cancelButtonVariant}>
|
||||
{cancelButtonText ?? t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
{React.cloneElement(children, {
|
||||
// @ts-expect-error broke with @types/react upgrade. TODO: figure out narrower type than React.ReactNode
|
||||
onClick: openDialog, // TODO: when SendouButton has overtaken Button, this line can be removed
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
import { Combobox } from "@headlessui/react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
||||
export const UserSearch = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
inputName?: string;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
initialUserId?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
userIdsToOmit?: Set<number>;
|
||||
required?: boolean;
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
inputName,
|
||||
onChange,
|
||||
initialUserId,
|
||||
id,
|
||||
className,
|
||||
userIdsToOmit,
|
||||
required,
|
||||
onBlur,
|
||||
disabled,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUser, setSelectedUser] =
|
||||
React.useState<UserSearchUserItem | null>(null);
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!query) return;
|
||||
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||
},
|
||||
1000,
|
||||
[query],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!initialUserFetcher.data) return;
|
||||
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||
}, [initialUserFetcher.data]);
|
||||
|
||||
const allUsers = queryFetcher.data?.users ?? [];
|
||||
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||
const noMatches = queryFetcher.data && users.length === 0;
|
||||
const initialSelectionIsLoading = Boolean(
|
||||
initialUserId && !initialUserFetcher.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
{selectedUser && inputName ? (
|
||||
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||
) : null}
|
||||
<Combobox
|
||||
value={selectedUser}
|
||||
onChange={(newUser) => {
|
||||
setSelectedUser(newUser);
|
||||
onChange?.(newUser!);
|
||||
}}
|
||||
disabled={disabled || initialSelectionIsLoading}
|
||||
>
|
||||
<Combobox.Input
|
||||
ref={ref}
|
||||
placeholder={
|
||||
initialSelectionIsLoading
|
||||
? t("actions.loading")
|
||||
: "Search via name or ID..."
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(user: UserSearchUserItem) => user?.username ?? ""}
|
||||
className={clsx("combobox-input", className)}
|
||||
data-1p-ignore
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Combobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !queryFetcher.data,
|
||||
})}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : null}
|
||||
{users.map((user, i) => (
|
||||
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||
{({ active }) => (
|
||||
<li
|
||||
className={clsx("combobox-item", { active })}
|
||||
data-testid={`combobox-option-${i}`}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
<div>
|
||||
<div className="stack xs horizontal items-center">
|
||||
<span className="combobox-username">
|
||||
{user.username}
|
||||
</span>{" "}
|
||||
{user.plusTier ? (
|
||||
<span className="text-xxs">+{user.plusTier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{user.discordUniqueName ? (
|
||||
<div className="text-xs">{user.discordUniqueName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
23
app/components/elements/BottomTexts.tsx
Normal file
23
app/components/elements/BottomTexts.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { SendouFieldError } from "~/components/elements/FieldError";
|
||||
import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
||||
|
||||
export function SendouBottomTexts({
|
||||
bottomText,
|
||||
errorText,
|
||||
}: {
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{errorText ? (
|
||||
<SendouFieldError>{errorText}</SendouFieldError>
|
||||
) : (
|
||||
<SendouFieldError />
|
||||
)}
|
||||
{bottomText && !errorText ? (
|
||||
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ type ButtonVariant =
|
|||
| "minimal-success"
|
||||
| "minimal-destructive";
|
||||
|
||||
interface MyDatePickerProps extends ReactAriaButtonProps {
|
||||
interface SendouButtonProps extends ReactAriaButtonProps {
|
||||
variant?: ButtonVariant;
|
||||
size?: "miniscule" | "small" | "medium" | "big";
|
||||
icon?: JSX.Element;
|
||||
|
|
@ -31,7 +31,7 @@ export function SendouButton({
|
|||
className,
|
||||
icon,
|
||||
...rest
|
||||
}: MyDatePickerProps) {
|
||||
}: SendouButtonProps) {
|
||||
const variantClassname = variant ? variantToClassname(variant) : null;
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Popover,
|
||||
DatePicker as ReactAriaDatePicker,
|
||||
} from "react-aria-components";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import {
|
||||
type FormFieldSize,
|
||||
formFieldSizeToClassName,
|
||||
|
|
@ -21,8 +22,6 @@ import {
|
|||
import { ArrowLeftIcon } from "../icons/ArrowLeft";
|
||||
import { ArrowRightIcon } from "../icons/ArrowRight";
|
||||
import { CalendarIcon } from "../icons/Calendar";
|
||||
import { SendouFieldError } from "./FieldError";
|
||||
import { SendouFieldMessage } from "./FieldMessage";
|
||||
import { SendouLabel } from "./Label";
|
||||
|
||||
interface SendouDatePickerProps<T extends DateValue>
|
||||
|
|
@ -52,10 +51,7 @@ export function SendouDatePicker<T extends DateValue>({
|
|||
<CalendarIcon />
|
||||
</Button>
|
||||
</Group>
|
||||
{errorText && <SendouFieldError>{errorText}</SendouFieldError>}
|
||||
{bottomText && !errorText ? (
|
||||
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
|
||||
) : null}
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<Calendar>
|
||||
|
|
|
|||
90
app/components/elements/Dialog.module.css
Normal file
90
app/components/elements/Dialog.module.css
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
overflow-y: auto;
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10px); /* Adjust blur value as needed */
|
||||
}
|
||||
|
||||
.fullScreenOverlay {
|
||||
padding: 0;
|
||||
display: initial;
|
||||
}
|
||||
|
||||
.overlay[data-entering] {
|
||||
animation: fade-in 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background-color: var(--bg-lighter-solid);
|
||||
border: 2.5px solid var(--border);
|
||||
padding: var(--s-6);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px
|
||||
rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.fullScreenModal {
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal[data-entering] {
|
||||
animation: zoom-in-95 300ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes zoom-in-95 {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog {
|
||||
outline: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.headingContainer {
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-block-end: var(--s-2);
|
||||
margin-block-end: var(--s-4);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-block-start: -3px;
|
||||
}
|
||||
|
||||
.noHeading {
|
||||
margin-block-start: -14px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
144
app/components/elements/Dialog.tsx
Normal file
144
app/components/elements/Dialog.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import type { ModalOverlayProps } from "react-aria-components";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Heading,
|
||||
ModalOverlay,
|
||||
} from "react-aria-components";
|
||||
import { Modal } from "react-aria-components";
|
||||
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import styles from "./Dialog.module.css";
|
||||
|
||||
interface SendouDialogProps extends ModalOverlayProps {
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
heading?: string;
|
||||
showHeading?: boolean;
|
||||
onClose?: () => void;
|
||||
/** When closing the modal which URL to navigate to */
|
||||
onCloseTo?: string;
|
||||
overlayClassName?: string;
|
||||
"aria-label"?: string;
|
||||
/** If true, the modal takes over the full screen with the content below hidden */
|
||||
isFullScreen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows you to create a dialog with a customizable trigger and content.
|
||||
* It supports both controlled and uncontrolled modes for managing the dialog's open state.
|
||||
*
|
||||
* @example
|
||||
* // Example usage with implicit isOpen
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* onCloseTo={previousPageUrl()}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Example usage with a SendouButton as the trigger
|
||||
* return (
|
||||
* <SendouDialog
|
||||
* heading="Dialog Title"
|
||||
* trigger={<SendouButton>Open Dialog</SendouButton>}
|
||||
* >
|
||||
* This is the dialog content.
|
||||
* </SendouDialog>
|
||||
* );
|
||||
*/
|
||||
export function SendouDialog({
|
||||
trigger,
|
||||
children,
|
||||
...rest
|
||||
}: SendouDialogProps) {
|
||||
if (!trigger) {
|
||||
const props =
|
||||
typeof rest.isOpen === "boolean" ? rest : { isOpen: true, ...rest };
|
||||
return <DialogModal {...props}>{children}</DialogModal>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{trigger}
|
||||
<DialogModal {...rest}>{children}</DialogModal>
|
||||
</DialogTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogModal({
|
||||
children,
|
||||
heading,
|
||||
showHeading = true,
|
||||
className,
|
||||
...rest
|
||||
}: Omit<SendouDialogProps, "trigger">) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const showCloseButton = rest.onClose || rest.onCloseTo;
|
||||
const onClose = () => {
|
||||
if (rest.onCloseTo) {
|
||||
navigate(rest.onCloseTo);
|
||||
} else if (rest.onClose) {
|
||||
rest.onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (rest.onCloseTo) {
|
||||
navigate(rest.onCloseTo);
|
||||
} else if (rest.onClose) {
|
||||
rest.onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
className={clsx(rest.overlayClassName, styles.overlay, {
|
||||
[styles.fullScreenOverlay]: rest.isFullScreen,
|
||||
})}
|
||||
onOpenChange={rest.onOpenChange ?? onOpenChange}
|
||||
{...rest}
|
||||
>
|
||||
<Modal
|
||||
className={clsx(className, styles.modal, {
|
||||
[styles.fullScreenModal]: rest.isFullScreen,
|
||||
})}
|
||||
>
|
||||
<Dialog className={styles.dialog} aria-label={rest["aria-label"]}>
|
||||
{showHeading ? (
|
||||
<div
|
||||
className={clsx(styles.headingContainer, {
|
||||
[styles.noHeading]: !heading,
|
||||
})}
|
||||
>
|
||||
{heading ? (
|
||||
<Heading slot="title" className={styles.heading}>
|
||||
{heading}
|
||||
</Heading>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<SendouButton
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal-destructive"
|
||||
className="ml-auto"
|
||||
slot="close"
|
||||
onPress={onClose}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
||||
|
||||
export function SendouFieldError({ children }: { children: React.ReactNode }) {
|
||||
export function SendouFieldError({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ReactAriaFieldError className="error-message">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@
|
|||
border: none;
|
||||
}
|
||||
|
||||
[data-empty] .searchClearButton {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.noResults {
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--bold);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,8 @@
|
|||
import clsx from "clsx";
|
||||
import type {
|
||||
ListBoxItemProps,
|
||||
SelectProps,
|
||||
ValidationResult,
|
||||
} from "react-aria-components";
|
||||
import type { ListBoxItemProps, SelectProps } from "react-aria-components";
|
||||
import {
|
||||
Autocomplete,
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
ListBox,
|
||||
|
|
@ -17,11 +12,11 @@ import {
|
|||
SearchField,
|
||||
Select,
|
||||
SelectValue,
|
||||
Text,
|
||||
Virtualizer,
|
||||
useFilter,
|
||||
} from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { CrossIcon } from "../icons/Cross";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
|
|
@ -31,7 +26,8 @@ interface SendouSelectProps<T extends object>
|
|||
extends Omit<SelectProps<T>, "children"> {
|
||||
label?: string;
|
||||
description?: string;
|
||||
errorMessage?: string | ((validation: ValidationResult) => string);
|
||||
errorText?: string;
|
||||
bottomText?: string;
|
||||
items?: Iterable<T>;
|
||||
children: React.ReactNode | ((item: T) => React.ReactNode);
|
||||
search?: {
|
||||
|
|
@ -42,7 +38,8 @@ interface SendouSelectProps<T extends object>
|
|||
export function SendouSelect<T extends object>({
|
||||
label,
|
||||
description,
|
||||
errorMessage,
|
||||
errorText,
|
||||
bottomText,
|
||||
children,
|
||||
items,
|
||||
search,
|
||||
|
|
@ -60,8 +57,7 @@ export function SendouSelect<T extends object>({
|
|||
<ChevronUpDownIcon className={styles.icon} />
|
||||
</span>
|
||||
</Button>
|
||||
{description && <Text slot="description">{description}</Text>}
|
||||
<FieldError>{errorMessage}</FieldError>
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover className={styles.popover}>
|
||||
<Autocomplete filter={contains}>
|
||||
{search ? (
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
gap: 8px;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 55px;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
|
|
|||
57
app/components/elements/UserSearch.module.css
Normal file
57
app/components/elements/UserSearch.module.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
.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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.popover {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.itemTextsContainer {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.selectValue {
|
||||
max-width: calc(var(--select-width) - 55px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
button:disabled .selectValue {
|
||||
color: var(--text-lighter);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
text-align: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 162px;
|
||||
margin-block: var(--s-4);
|
||||
}
|
||||
|
||||
.itemAdditionalText {
|
||||
font-size: var(--fonts-xxsm);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
button .itemAdditionalText {
|
||||
display: none;
|
||||
}
|
||||
248
app/components/elements/UserSearch.tsx
Normal file
248
app/components/elements/UserSearch.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
type Key,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
SearchField,
|
||||
Select,
|
||||
type SelectProps,
|
||||
SelectValue,
|
||||
} from "react-aria-components";
|
||||
import { Autocomplete } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "react-use";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouLabel } from "~/components/elements/Label";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import type { UserSearchLoaderData } from "~/features/user-search/loaders/u.server";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
|
||||
import selectStyles from "./Select.module.css";
|
||||
import userSearchStyles from "./UserSearch.module.css";
|
||||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
||||
interface UserSearchProps<T extends object>
|
||||
extends Omit<SelectProps<T>, "children"> {
|
||||
name?: string;
|
||||
label?: string;
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
initialUserId?: number;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
}
|
||||
|
||||
export const UserSearch = React.forwardRef(function UserSearch<
|
||||
T extends object,
|
||||
>(
|
||||
{
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
errorText,
|
||||
initialUserId,
|
||||
onChange,
|
||||
...rest
|
||||
}: UserSearchProps<T>,
|
||||
ref?: React.Ref<HTMLButtonElement>,
|
||||
) {
|
||||
const [selectedKey, setSelectedKey] = React.useState(initialUserId ?? null);
|
||||
const { initialUser, ...list } = useUserSearch(setSelectedKey, initialUserId);
|
||||
|
||||
const onSelectionChange = (userId: number) => {
|
||||
setSelectedKey(userId);
|
||||
onChange?.(
|
||||
list.items.find((user) => user.id === userId) as UserSearchUserItem,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
name={name}
|
||||
placeholder=""
|
||||
selectedKey={selectedKey}
|
||||
onSelectionChange={onSelectionChange as (key: Key) => void}
|
||||
{...rest}
|
||||
>
|
||||
{label ? (
|
||||
<SendouLabel required={rest.isRequired}>{label}</SendouLabel>
|
||||
) : null}
|
||||
<Button className={selectStyles.button} ref={ref}>
|
||||
<SelectValue className={userSearchStyles.selectValue} />
|
||||
<span aria-hidden="true">
|
||||
<ChevronUpDownIcon className={selectStyles.icon} />
|
||||
</span>
|
||||
</Button>
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<Popover className={clsx(selectStyles.popover, userSearchStyles.popover)}>
|
||||
<Autocomplete
|
||||
inputValue={list.filterText}
|
||||
onInputChange={list.setFilterText}
|
||||
>
|
||||
<SearchField
|
||||
aria-label="Search"
|
||||
autoFocus
|
||||
className={selectStyles.searchField}
|
||||
>
|
||||
<SearchIcon aria-hidden className={selectStyles.smallIcon} />
|
||||
<Input
|
||||
className={clsx("plain", selectStyles.searchInput)}
|
||||
data-testid="user-search-input"
|
||||
/>
|
||||
<Button className={selectStyles.searchClearButton}>
|
||||
<CrossIcon className={selectStyles.smallIcon} />
|
||||
</Button>
|
||||
</SearchField>
|
||||
<ListBox
|
||||
items={[initialUser, ...list.items].filter(
|
||||
(user) => user !== undefined,
|
||||
)}
|
||||
className={selectStyles.listBox}
|
||||
>
|
||||
{(item) => <UserItem item={item as UserSearchUserItem} />}
|
||||
</ListBox>
|
||||
</Autocomplete>
|
||||
</Popover>
|
||||
</Select>
|
||||
);
|
||||
});
|
||||
|
||||
function UserItem({
|
||||
item,
|
||||
}: {
|
||||
item:
|
||||
| UserSearchUserItem
|
||||
| {
|
||||
id: "NO_RESULTS";
|
||||
}
|
||||
| {
|
||||
id: "PLACEHOLDER";
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
// for some reason the `renderEmptyState` on ListBox is not working
|
||||
// so doing this as a workaround
|
||||
if (typeof item.id === "string") {
|
||||
return (
|
||||
<ListBoxItem
|
||||
id="PLACEHOLDER"
|
||||
textValue="PLACEHOLDER"
|
||||
isDisabled
|
||||
className={userSearchStyles.placeholder}
|
||||
>
|
||||
{item.id === "PLACEHOLDER"
|
||||
? t("common:forms.userSearch.placeholder")
|
||||
: t("common:forms.userSearch.noResults")}
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
const additionalText = () => {
|
||||
const plusServer = item.plusTier ? `+${item.plusTier}` : "";
|
||||
const profileUrl = item.customUrl ? `/u/${item.customUrl}` : "";
|
||||
|
||||
if (plusServer && profileUrl) {
|
||||
return `${plusServer} • ${profileUrl}`;
|
||||
}
|
||||
|
||||
if (plusServer) {
|
||||
return plusServer;
|
||||
}
|
||||
|
||||
if (profileUrl) {
|
||||
return profileUrl;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
return (
|
||||
<ListBoxItem
|
||||
id={item.id}
|
||||
textValue={item.username}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
clsx(userSearchStyles.item, {
|
||||
[selectStyles.itemFocused]: isFocused,
|
||||
[selectStyles.itemSelected]: isSelected,
|
||||
})
|
||||
}
|
||||
data-testid="user-search-item"
|
||||
>
|
||||
<Avatar user={item} size="xxs" />
|
||||
<div className={userSearchStyles.itemTextsContainer}>
|
||||
{item.username}
|
||||
{additionalText() ? (
|
||||
<div className={userSearchStyles.itemAdditionalText}>
|
||||
{additionalText()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ListBoxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function useUserSearch(
|
||||
setSelectedKey: (userId: number | null) => void,
|
||||
initialUserId?: number,
|
||||
) {
|
||||
const [filterText, setFilterText] = React.useState("");
|
||||
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialUserId !== undefined) {
|
||||
setSelectedKey(initialUserId);
|
||||
}
|
||||
}, [initialUserId, setSelectedKey]);
|
||||
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!filterText) return;
|
||||
queryFetcher.load(`/u?q=${filterText}&limit=6`);
|
||||
setSelectedKey(null);
|
||||
},
|
||||
500,
|
||||
[filterText],
|
||||
);
|
||||
|
||||
const items = () => {
|
||||
// data fetched for the query user has currently typed
|
||||
if (queryFetcher.data && queryFetcher.data.query === filterText) {
|
||||
if (queryFetcher.data.users.length === 0) {
|
||||
return [{ id: "NO_RESULTS" }];
|
||||
}
|
||||
return queryFetcher.data.users;
|
||||
}
|
||||
|
||||
return [{ id: "PLACEHOLDER" }];
|
||||
};
|
||||
|
||||
const initialUser = initialUserFetcher.data?.users[0];
|
||||
|
||||
return {
|
||||
filterText,
|
||||
setFilterText,
|
||||
items: items(),
|
||||
initialUser,
|
||||
};
|
||||
}
|
||||
|
|
@ -6,22 +6,20 @@ 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 { LinkButton } from "../Button";
|
||||
import { SubmitButton } from "../SubmitButton";
|
||||
|
||||
export function MyForm<T extends z.ZodTypeAny>({
|
||||
schema,
|
||||
defaultValues,
|
||||
title,
|
||||
heading,
|
||||
children,
|
||||
handleCancel,
|
||||
cancelLink,
|
||||
}: {
|
||||
schema: T;
|
||||
defaultValues?: DefaultValues<z.infer<T>>;
|
||||
title?: string;
|
||||
heading?: string;
|
||||
children: React.ReactNode;
|
||||
handleCancel?: () => void;
|
||||
cancelLink?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
|
@ -55,21 +53,12 @@ export function MyForm<T extends z.ZodTypeAny>({
|
|||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
|
||||
{title ? <h1 className="text-lg">{title}</h1> : null}
|
||||
{heading ? <h1 className="text-lg">{heading}</h1> : null}
|
||||
{children}
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { UserSearch } from "../UserSearch";
|
||||
import { UserSearch } from "../elements/UserSearch";
|
||||
|
||||
export function UserSearchFormField<T extends FieldValues>({
|
||||
label,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
import { useSearchParams } from "@remix-run/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { LOG_IN_URL, SENDOU_INK_DISCORD_URL } from "~/utils/urls";
|
||||
import { Button } from "../Button";
|
||||
import { Dialog } from "../Dialog";
|
||||
|
||||
export function LogInButtonContainer({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMounted = useIsMounted();
|
||||
const [searchParams] = useSearchParams();
|
||||
const authError = searchParams.get("authError");
|
||||
const closeAuthErrorDialog = () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.delete("authError");
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -29,40 +23,34 @@ export function LogInButtonContainer({
|
|||
{authError != null &&
|
||||
isMounted &&
|
||||
createPortal(
|
||||
<Dialog isOpen close={closeAuthErrorDialog}>
|
||||
<SendouDialog
|
||||
isDismissable
|
||||
onCloseTo="/"
|
||||
heading={
|
||||
authError === "aborted"
|
||||
? t("auth.errors.aborted")
|
||||
: t("auth.errors.failed")
|
||||
}
|
||||
>
|
||||
<div className="stack md layout__user-item">
|
||||
<AuthenticationErrorHelp errorCode={authError} />
|
||||
<Button onClick={closeAuthErrorDialog}>
|
||||
{t("actions.close")}
|
||||
</Button>
|
||||
{authError === "aborted" ? (
|
||||
<>{t("auth.errors.discordPermissions")}</>
|
||||
) : (
|
||||
<>
|
||||
{t("auth.errors.unknown")}{" "}
|
||||
<a
|
||||
href={SENDOU_INK_DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>,
|
||||
</SendouDialog>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticationErrorHelp({ errorCode }: { errorCode: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
switch (errorCode) {
|
||||
case "aborted":
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-lg text-center">{t("auth.errors.aborted")}</h2>
|
||||
{t("auth.errors.discordPermissions")}
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-lg text-center">{t("auth.errors.failed")}</h2>
|
||||
{t("auth.errors.unknown")}{" "}
|
||||
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Link } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls";
|
||||
import { Avatar } from "../Avatar";
|
||||
import { Button } from "../Button";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { Image } from "../Image";
|
||||
import { CrossIcon } from "../icons/Cross";
|
||||
import { LogOutIcon } from "../icons/LogOut";
|
||||
|
|
@ -23,7 +23,12 @@ export function NavDialog({
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog isOpen className="layout__overlay-nav__dialog">
|
||||
<SendouDialog
|
||||
className="layout__overlay-nav__dialog"
|
||||
showHeading={false}
|
||||
aria-label="Site navigation"
|
||||
isFullScreen
|
||||
>
|
||||
<Button
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal-destructive"
|
||||
|
|
@ -54,7 +59,7 @@ export function NavDialog({
|
|||
))}
|
||||
</div>
|
||||
{user ? (
|
||||
<div className="mt-6 w-max mx-auto">
|
||||
<div className="mt-6 stack items-center">
|
||||
<form method="post" action={LOG_OUT_URL}>
|
||||
<Button
|
||||
size="tiny"
|
||||
|
|
@ -67,7 +72,7 @@ export function NavDialog({
|
|||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -185,6 +185,8 @@ export async function seed(variation?: SeedVariation | null) {
|
|||
|
||||
count++;
|
||||
|
||||
faker.seed(5800);
|
||||
|
||||
await seedFunc();
|
||||
}
|
||||
|
||||
|
|
@ -349,7 +351,7 @@ async function userProfiles() {
|
|||
|
||||
for (let id = 2; id < 500; id++) {
|
||||
if (id === ADMIN_ID || id === NZAP_TEST_ID) continue;
|
||||
if (Math.random() < 0.25) continue; // 75% have bio
|
||||
if (faker.number.float(1) < 0.25) continue; // 75% have bio
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
|
|
@ -361,15 +363,16 @@ async function userProfiles() {
|
|||
faker.helpers.arrayElement([1, 1, 1, 2, 3, 4]),
|
||||
"\n\n",
|
||||
),
|
||||
country: Math.random() > 0.5 ? faker.location.countryCode() : null,
|
||||
country:
|
||||
faker.number.float(1) > 0.5 ? faker.location.countryCode() : null,
|
||||
});
|
||||
}
|
||||
|
||||
for (let id = 2; id < 500; id++) {
|
||||
if (id === ADMIN_ID || id === NZAP_TEST_ID) continue;
|
||||
if (Math.random() < 0.15) continue; // 85% have weapons
|
||||
if (faker.number.float(1) < 0.15) continue; // 85% have weapons
|
||||
|
||||
const weapons = R.shuffle(mainWeaponIds);
|
||||
const weapons = faker.helpers.shuffle(mainWeaponIds);
|
||||
|
||||
for (let j = 0; j < faker.helpers.arrayElement([1, 2, 3, 4, 5]); j++) {
|
||||
sql
|
||||
|
|
@ -390,24 +393,24 @@ async function userProfiles() {
|
|||
userId: id,
|
||||
weaponSplId: weapons.pop()!,
|
||||
order: j + 1,
|
||||
isFavorite: Math.random() > 0.8 ? 1 : 0,
|
||||
isFavorite: faker.number.float(1) > 0.8 ? 1 : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let id = 1; id < 500; id++) {
|
||||
const defaultLanguages = Math.random() > 0.1 ? ["en"] : [];
|
||||
if (Math.random() > 0.9) defaultLanguages.push("es");
|
||||
if (Math.random() > 0.9) defaultLanguages.push("fr");
|
||||
if (Math.random() > 0.9) defaultLanguages.push("de");
|
||||
if (Math.random() > 0.9) defaultLanguages.push("it");
|
||||
if (Math.random() > 0.9) defaultLanguages.push("ja");
|
||||
const defaultLanguages = faker.number.float(1) > 0.1 ? ["en"] : [];
|
||||
if (faker.number.float(1) > 0.9) defaultLanguages.push("es");
|
||||
if (faker.number.float(1) > 0.9) defaultLanguages.push("fr");
|
||||
if (faker.number.float(1) > 0.9) defaultLanguages.push("de");
|
||||
if (faker.number.float(1) > 0.9) defaultLanguages.push("it");
|
||||
if (faker.number.float(1) > 0.9) defaultLanguages.push("ja");
|
||||
|
||||
await QSettingsRepository.updateVoiceChat({
|
||||
languages: defaultLanguages,
|
||||
userId: id,
|
||||
vc:
|
||||
Math.random() > 0.2
|
||||
faker.number.float(1) > 0.2
|
||||
? "YES"
|
||||
: faker.helpers.arrayElement(["YES", "NO", "LISTEN_ONLY"]),
|
||||
});
|
||||
|
|
@ -416,13 +419,13 @@ async function userProfiles() {
|
|||
|
||||
const randomPreferences = (): UserMapModePreferences => {
|
||||
const modes: UserMapModePreferences["modes"] = modesShort.flatMap((mode) => {
|
||||
if (Math.random() > 0.5 && mode !== "SZ") return [];
|
||||
if (faker.number.float(1) > 0.5 && mode !== "SZ") return [];
|
||||
|
||||
const criteria = mode === "SZ" ? 0.2 : 0.5;
|
||||
|
||||
return {
|
||||
mode,
|
||||
preference: Math.random() > criteria ? "PREFER" : "AVOID",
|
||||
preference: faker.number.float(1) > criteria ? "PREFER" : "AVOID",
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -434,7 +437,8 @@ const randomPreferences = (): UserMapModePreferences => {
|
|||
|
||||
return {
|
||||
mode,
|
||||
stages: R.shuffle(stageIds)
|
||||
stages: faker.helpers
|
||||
.shuffle(stageIds)
|
||||
.filter((stageId) => !BANNED_MAPS[mode].includes(stageId))
|
||||
.slice(0, AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
|
||||
};
|
||||
|
|
@ -444,7 +448,7 @@ const randomPreferences = (): UserMapModePreferences => {
|
|||
|
||||
async function userMapModePreferences() {
|
||||
for (let id = 1; id < 500; id++) {
|
||||
if (id !== ADMIN_ID && Math.random() < 0.2) continue; // 80% have maps && admin always
|
||||
if (id !== ADMIN_ID && faker.number.float(1) < 0.2) continue; // 80% have maps && admin always
|
||||
|
||||
await db
|
||||
.updateTable("User")
|
||||
|
|
@ -459,12 +463,11 @@ async function userMapModePreferences() {
|
|||
async function userQWeaponPool() {
|
||||
for (let id = 1; id < 500; id++) {
|
||||
if (id === 2) continue; // no weapons for N-ZAP
|
||||
if (Math.random() < 0.2) continue; // 80% have weapons
|
||||
if (faker.number.float(1) < 0.2) continue; // 80% have weapons
|
||||
|
||||
const weapons = R.shuffle(mainWeaponIds).slice(
|
||||
0,
|
||||
faker.helpers.arrayElement([1, 2, 3, 4]),
|
||||
);
|
||||
const weapons = faker.helpers
|
||||
.shuffle(mainWeaponIds)
|
||||
.slice(0, faker.helpers.arrayElement([1, 2, 3, 4]));
|
||||
|
||||
await db
|
||||
.updateTable("User")
|
||||
|
|
@ -603,7 +606,7 @@ function syncPlusTiers() {
|
|||
}
|
||||
|
||||
function getAvailableBadgeIds() {
|
||||
return R.shuffle(
|
||||
return faker.helpers.shuffle(
|
||||
(sql.prepare(`select "id" from "Badge"`).all() as any[]).map((b) => b.id),
|
||||
);
|
||||
}
|
||||
|
|
@ -624,7 +627,7 @@ function badgesToUsers() {
|
|||
);
|
||||
|
||||
for (const id of availableBadgeIds) {
|
||||
userIds = R.shuffle(userIds);
|
||||
userIds = faker.helpers.shuffle(userIds);
|
||||
for (
|
||||
let i = 0;
|
||||
i <
|
||||
|
|
@ -715,7 +718,7 @@ function calendarEvents() {
|
|||
const userIds = userIdsInRandomOrder();
|
||||
|
||||
for (let id = 1; id <= AMOUNT_OF_CALENDAR_EVENTS; id++) {
|
||||
const shuffledTags = R.shuffle(Object.keys(persistedTags));
|
||||
const shuffledTags = faker.helpers.shuffle(Object.keys(persistedTags));
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
|
|
@ -749,7 +752,7 @@ function calendarEvents() {
|
|||
bracketUrl: faker.internet.url(),
|
||||
authorId: id === 1 ? NZAP_TEST_ID : (userIds.pop() ?? null),
|
||||
tags:
|
||||
Math.random() > 0.2
|
||||
faker.number.float(1) > 0.2
|
||||
? shuffledTags
|
||||
.slice(
|
||||
0,
|
||||
|
|
@ -761,7 +764,7 @@ function calendarEvents() {
|
|||
: null,
|
||||
});
|
||||
|
||||
const twoDayEvent = Math.random() > 0.9;
|
||||
const twoDayEvent = faker.number.float(1) > 0.9;
|
||||
const startTime =
|
||||
id % 2 === 0
|
||||
? faker.date.soon({ days: 42 })
|
||||
|
|
@ -810,7 +813,7 @@ function calendarEvents() {
|
|||
|
||||
function calendarEventBadges() {
|
||||
for (let eventId = 1; eventId <= AMOUNT_OF_CALENDAR_EVENTS; eventId++) {
|
||||
if (Math.random() > 0.25) continue;
|
||||
if (faker.number.float(1) > 0.25) continue;
|
||||
|
||||
const availableBadgeIds = getAvailableBadgeIds();
|
||||
|
||||
|
|
@ -847,7 +850,7 @@ async function calendarEventResults() {
|
|||
|
||||
for (const eventId of eventIdsOfPast) {
|
||||
// event id = 1 needs to be without results for e2e tests
|
||||
if (Math.random() < 0.3 || eventId === 1) continue;
|
||||
if (faker.number.float(1) < 0.3 || eventId === 1) continue;
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId,
|
||||
|
|
@ -862,7 +865,7 @@ async function calendarEventResults() {
|
|||
)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
const withStringName = Math.random() < 0.2;
|
||||
const withStringName = faker.number.float(1) < 0.2;
|
||||
|
||||
return {
|
||||
name: withStringName ? faker.person.firstName() : null,
|
||||
|
|
@ -1314,9 +1317,9 @@ function calendarEventWithToToolsTeams(
|
|||
if (
|
||||
event !== "SOS" &&
|
||||
event !== "LUTI" &&
|
||||
(Math.random() < 0.8 || id === 1)
|
||||
(faker.number.float(1) < 0.8 || id === 1)
|
||||
) {
|
||||
const shuffledPairs = R.shuffle(availablePairs.slice());
|
||||
const shuffledPairs = faker.helpers.shuffle(availablePairs.slice());
|
||||
|
||||
let SZ = 0;
|
||||
let TC = 0;
|
||||
|
|
@ -1399,7 +1402,7 @@ function tournamentSubs() {
|
|||
.run({
|
||||
userId: id,
|
||||
tournamentId: 1,
|
||||
canVc: Number(Math.random() > 0.5),
|
||||
canVc: Number(faker.number.float(1) > 0.5),
|
||||
bestWeapons: nullFilledArray(
|
||||
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
|
||||
)
|
||||
|
|
@ -1414,7 +1417,7 @@ function tournamentSubs() {
|
|||
})
|
||||
.join(","),
|
||||
okWeapons:
|
||||
Math.random() > 0.5
|
||||
faker.number.float(1) > 0.5
|
||||
? null
|
||||
: nullFilledArray(
|
||||
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
|
||||
|
|
@ -1429,7 +1432,7 @@ function tournamentSubs() {
|
|||
}
|
||||
})
|
||||
.join(","),
|
||||
message: Math.random() > 0.5 ? null : faker.lorem.paragraph(),
|
||||
message: faker.number.float(1) > 0.5 ? null : faker.lorem.paragraph(),
|
||||
visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL",
|
||||
});
|
||||
}
|
||||
|
|
@ -1438,19 +1441,21 @@ function tournamentSubs() {
|
|||
}
|
||||
|
||||
const randomAbility = (legalTypes: AbilityType[]) => {
|
||||
const randomOrderAbilities = R.shuffle([...abilities]);
|
||||
const randomOrderAbilities = faker.helpers.shuffle([...abilities]);
|
||||
|
||||
return randomOrderAbilities.find((a) => legalTypes.includes(a.type))!.name;
|
||||
};
|
||||
|
||||
const adminWeaponPool = mainWeaponIds.filter(() => Math.random() > 0.8);
|
||||
const adminWeaponPool = mainWeaponIds.filter(() => faker.number.float(1) > 0.8);
|
||||
async function adminBuilds() {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const randomOrderHeadGear = R.shuffle(headGearIds.slice());
|
||||
const randomOrderClothesGear = R.shuffle(clothesGearIds.slice());
|
||||
const randomOrderShoesGear = R.shuffle(shoesGearIds.slice());
|
||||
const randomOrderHeadGear = faker.helpers.shuffle(headGearIds.slice());
|
||||
const randomOrderClothesGear = faker.helpers.shuffle(
|
||||
clothesGearIds.slice(),
|
||||
);
|
||||
const randomOrderShoesGear = faker.helpers.shuffle(shoesGearIds.slice());
|
||||
// filter out sshot to prevent test flaking
|
||||
const randomOrderWeaponIds = R.shuffle(
|
||||
const randomOrderWeaponIds = faker.helpers.shuffle(
|
||||
adminWeaponPool.filter((id) => id !== 40).slice(),
|
||||
);
|
||||
|
||||
|
|
@ -1460,7 +1465,8 @@ async function adminBuilds() {
|
|||
)}`,
|
||||
ownerId: ADMIN_ID,
|
||||
private: 0,
|
||||
description: Math.random() < 0.75 ? faker.lorem.paragraph() : null,
|
||||
description:
|
||||
faker.number.float(1) < 0.75 ? faker.lorem.paragraph() : null,
|
||||
headGearSplId: randomOrderHeadGear[0],
|
||||
clothesGearSplId: randomOrderClothesGear[0],
|
||||
shoesGearSplId: randomOrderShoesGear[0],
|
||||
|
|
@ -1470,8 +1476,8 @@ async function adminBuilds() {
|
|||
.fill(null)
|
||||
.map(() => randomOrderWeaponIds.pop()!),
|
||||
modes:
|
||||
Math.random() < 0.75
|
||||
? modesShort.filter(() => Math.random() < 0.5)
|
||||
faker.number.float(1) < 0.75
|
||||
? modesShort.filter(() => faker.number.float(1) < 0.5)
|
||||
: null,
|
||||
abilities: [
|
||||
[
|
||||
|
|
@ -1509,12 +1515,14 @@ async function manySplattershotBuilds() {
|
|||
for (let i = 0; i < 499; i++) {
|
||||
const SPLATTERSHOT_ID = 40;
|
||||
|
||||
const randomOrderHeadGear = R.shuffle(headGearIds.slice());
|
||||
const randomOrderClothesGear = R.shuffle(clothesGearIds.slice());
|
||||
const randomOrderShoesGear = R.shuffle(shoesGearIds.slice());
|
||||
const randomOrderWeaponIds = R.shuffle(mainWeaponIds.slice()).filter(
|
||||
(id) => id !== SPLATTERSHOT_ID,
|
||||
const randomOrderHeadGear = faker.helpers.shuffle(headGearIds.slice());
|
||||
const randomOrderClothesGear = faker.helpers.shuffle(
|
||||
clothesGearIds.slice(),
|
||||
);
|
||||
const randomOrderShoesGear = faker.helpers.shuffle(shoesGearIds.slice());
|
||||
const randomOrderWeaponIds = faker.helpers
|
||||
.shuffle(mainWeaponIds.slice())
|
||||
.filter((id) => id !== SPLATTERSHOT_ID);
|
||||
|
||||
const ownerId = users.pop()!;
|
||||
|
||||
|
|
@ -1524,7 +1532,8 @@ async function manySplattershotBuilds() {
|
|||
faker.word.noun(),
|
||||
)}`,
|
||||
ownerId,
|
||||
description: Math.random() < 0.75 ? faker.lorem.paragraph() : null,
|
||||
description:
|
||||
faker.number.float(1) < 0.75 ? faker.lorem.paragraph() : null,
|
||||
headGearSplId: randomOrderHeadGear[0],
|
||||
clothesGearSplId: randomOrderClothesGear[0],
|
||||
shoesGearSplId: randomOrderShoesGear[0],
|
||||
|
|
@ -1536,8 +1545,8 @@ async function manySplattershotBuilds() {
|
|||
i === 0 ? SPLATTERSHOT_ID : randomOrderWeaponIds.pop()!,
|
||||
),
|
||||
modes:
|
||||
Math.random() < 0.75
|
||||
? modesShort.filter(() => Math.random() < 0.5)
|
||||
faker.number.float(1) < 0.75
|
||||
? modesShort.filter(() => faker.number.float(1) < 0.5)
|
||||
: null,
|
||||
abilities: [
|
||||
[
|
||||
|
|
@ -1930,7 +1939,8 @@ function arts() {
|
|||
).id,
|
||||
authorId: userId,
|
||||
isShowcase: i === 0 ? 1 : 0,
|
||||
description: Math.random() > 0.5 ? faker.lorem.paragraph() : null,
|
||||
description:
|
||||
faker.number.float(1) > 0.5 ? faker.lorem.paragraph() : null,
|
||||
}) as Tables["Art"];
|
||||
|
||||
if (i === 1) {
|
||||
|
|
@ -1960,7 +1970,7 @@ function commissionsOpen() {
|
|||
const allUsers = userIdsInRandomOrder();
|
||||
|
||||
for (const userId of allUsers) {
|
||||
if (Math.random() > 0.5) {
|
||||
if (faker.number.float(1) > 0.5) {
|
||||
updateCommissionStm.run({
|
||||
commissionsOpen: 1,
|
||||
commissionText: faker.lorem.paragraph(),
|
||||
|
|
@ -2019,15 +2029,15 @@ const randomMapList = (
|
|||
): TournamentMapListMap[] => {
|
||||
const szOnly = faker.helpers.arrayElement([true, false]);
|
||||
|
||||
let modePattern = R.shuffle([...modesShort]).filter(
|
||||
() => Math.random() > 0.15,
|
||||
);
|
||||
let modePattern = faker.helpers
|
||||
.shuffle([...modesShort])
|
||||
.filter(() => faker.number.float(1) > 0.15);
|
||||
if (modePattern.length === 0) {
|
||||
modePattern = R.shuffle([...rankedModesShort]);
|
||||
modePattern = faker.helpers.shuffle([...rankedModesShort]);
|
||||
}
|
||||
|
||||
const mapList: TournamentMapListMap[] = [];
|
||||
const stageIdsShuffled = R.shuffle([...stageIds]);
|
||||
const stageIdsShuffled = faker.helpers.shuffle([...stageIds]);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const mode = modePattern.pop()!;
|
||||
|
|
@ -2050,7 +2060,7 @@ const AMOUNT_OF_USERS_WITH_SKILLS = 100;
|
|||
async function playedMatches() {
|
||||
const _groupMembers = (() => {
|
||||
return new Array(AMOUNT_OF_USERS_WITH_SKILLS).fill(null).map(() => {
|
||||
const users = R.shuffle(
|
||||
const users = faker.helpers.shuffle(
|
||||
userIdsInAscendingOrderById().slice(0, AMOUNT_OF_USERS_WITH_SKILLS),
|
||||
);
|
||||
|
||||
|
|
@ -2061,14 +2071,14 @@ async function playedMatches() {
|
|||
userIdsInAscendingOrderById()
|
||||
.slice(0, AMOUNT_OF_USERS_WITH_SKILLS)
|
||||
.map((id) => {
|
||||
const weapons = R.shuffle([...mainWeaponIds]);
|
||||
const weapons = faker.helpers.shuffle([...mainWeaponIds]);
|
||||
return [id, weapons[0]];
|
||||
}),
|
||||
);
|
||||
|
||||
let matchDate = new Date(Date.UTC(2023, 9, 15, 0, 0, 0, 0));
|
||||
for (let i = 0; i < MATCHES_COUNT; i++) {
|
||||
const groupMembers = R.shuffle([..._groupMembers]);
|
||||
const groupMembers = faker.helpers.shuffle([..._groupMembers]);
|
||||
const groupAlphaMembers = groupMembers.pop()!;
|
||||
invariant(groupAlphaMembers, "groupAlphaMembers not found");
|
||||
|
||||
|
|
@ -2131,7 +2141,7 @@ async function playedMatches() {
|
|||
id: match.id,
|
||||
});
|
||||
|
||||
if (Math.random() > 0.95) {
|
||||
if (faker.number.float(1) > 0.95) {
|
||||
// increment date by 1 day
|
||||
matchDate = new Date(matchDate.getTime() + 1000 * 60 * 60 * 24);
|
||||
}
|
||||
|
|
@ -2175,7 +2185,9 @@ async function playedMatches() {
|
|||
reportScore({
|
||||
matchId: match.id,
|
||||
reportedByUserId:
|
||||
Math.random() > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0],
|
||||
faker.number.float(1) > 0.5
|
||||
? groupAlphaMembers[0]
|
||||
: groupBravoMembers[0],
|
||||
winners,
|
||||
});
|
||||
addSkills({
|
||||
|
|
@ -2193,7 +2205,7 @@ async function playedMatches() {
|
|||
})();
|
||||
|
||||
// -> add weapons for 90% of matches
|
||||
if (Math.random() > 0.9) continue;
|
||||
if (faker.number.float(1) > 0.9) continue;
|
||||
const users = [...groupAlphaMembers, ...groupBravoMembers];
|
||||
const mapsWithUsers = users.flatMap((u) =>
|
||||
finishedMatch.mapList.map((m) => ({ map: m, user: u })),
|
||||
|
|
@ -2202,13 +2214,13 @@ async function playedMatches() {
|
|||
addReportedWeapons(
|
||||
mapsWithUsers.map((mu) => {
|
||||
const weapon = () => {
|
||||
if (Math.random() < 0.9) return defaultWeapons[mu.user];
|
||||
if (Math.random() > 0.5)
|
||||
if (faker.number.float(1) < 0.9) return defaultWeapons[mu.user];
|
||||
if (faker.number.float(1) > 0.5)
|
||||
return (
|
||||
mainWeaponIds.find((id) => id > defaultWeapons[mu.user]) ?? 0
|
||||
);
|
||||
|
||||
const shuffled = R.shuffle([...mainWeaponIds]);
|
||||
const shuffled = faker.helpers.shuffle([...mainWeaponIds]);
|
||||
|
||||
return shuffled[0];
|
||||
};
|
||||
|
|
@ -2265,7 +2277,7 @@ async function scrimPosts() {
|
|||
const allUsers = userIdsInRandomOrder(true);
|
||||
|
||||
const date = () => {
|
||||
const isNow = Math.random() > 0.5;
|
||||
const isNow = faker.number.float(1) > 0.5;
|
||||
|
||||
if (isNow) {
|
||||
return databaseTimestampNow();
|
||||
|
|
@ -2284,7 +2296,7 @@ async function scrimPosts() {
|
|||
};
|
||||
|
||||
const team = () => {
|
||||
const hasTeam = Math.random() > 0.5;
|
||||
const hasTeam = faker.number.float(1) > 0.5;
|
||||
|
||||
if (!hasTeam) {
|
||||
return null;
|
||||
|
|
@ -2294,7 +2306,7 @@ async function scrimPosts() {
|
|||
};
|
||||
|
||||
const divRange = () => {
|
||||
const hasDivRange = Math.random() > 0.2;
|
||||
const hasDivRange = faker.number.float(1) > 0.2;
|
||||
|
||||
if (!hasDivRange) {
|
||||
return null;
|
||||
|
|
@ -2331,7 +2343,9 @@ async function scrimPosts() {
|
|||
minDiv: divs?.minDiv,
|
||||
teamId: team(),
|
||||
text:
|
||||
Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null,
|
||||
faker.number.float(1) > 0.5
|
||||
? faker.lorem.sentences({ min: 1, max: 5 })
|
||||
: null,
|
||||
visibility: null,
|
||||
users: users(),
|
||||
});
|
||||
|
|
@ -2340,7 +2354,9 @@ async function scrimPosts() {
|
|||
const adminPostId = await ScrimPostRepository.insert({
|
||||
at: date(),
|
||||
text:
|
||||
Math.random() > 0.5 ? faker.lorem.sentences({ min: 1, max: 5 }) : null,
|
||||
faker.number.float(1) > 0.5
|
||||
? faker.lorem.sentences({ min: 1, max: 5 })
|
||||
: null,
|
||||
visibility: null,
|
||||
users: users()
|
||||
.map((u) => ({ ...u, isOwner: 0 }))
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
ColumnType,
|
||||
GeneratedAlways,
|
||||
Insertable,
|
||||
JSONColumnType,
|
||||
Selectable,
|
||||
SqlBool,
|
||||
Updateable,
|
||||
|
|
@ -23,8 +24,9 @@ import type {
|
|||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import type { JSONColumnTypeNullable } from "~/utils/kysely.server";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
|
||||
|
|
@ -35,7 +37,7 @@ export interface Team {
|
|||
bannerImgId: number | null;
|
||||
bio: string | null;
|
||||
createdAt: Generated<number>;
|
||||
css: ColumnType<Record<string, string> | null, string | null, string | null>;
|
||||
css: JSONColumnTypeNullable<Record<string, string>>;
|
||||
customUrl: string;
|
||||
deletedAt: number | null;
|
||||
id: GeneratedAlways<number>;
|
||||
|
|
@ -100,7 +102,7 @@ export interface Build {
|
|||
description: string | null;
|
||||
headGearSplId: number;
|
||||
id: GeneratedAlways<number>;
|
||||
modes: ColumnType<ModeShort[] | null, string | null, string | null>;
|
||||
modes: JSONColumnTypeNullable<ModeShort[]>;
|
||||
ownerId: number;
|
||||
private: number | null;
|
||||
shoesGearSplId: number;
|
||||
|
|
@ -146,11 +148,7 @@ export interface CalendarEvent {
|
|||
organizationId: number | null;
|
||||
avatarImgId: number | null;
|
||||
// TODO: remove in migration
|
||||
avatarMetadata: ColumnType<
|
||||
CalendarEventAvatarMetadata | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
avatarMetadata: JSONColumnTypeNullable<CalendarEventAvatarMetadata>;
|
||||
}
|
||||
|
||||
export interface CalendarEventBadge {
|
||||
|
|
@ -250,7 +248,7 @@ export interface GroupMatch {
|
|||
chatCode: string | null;
|
||||
createdAt: Generated<number>;
|
||||
id: GeneratedAlways<number>;
|
||||
memento: ColumnType<ParsedMemento | null, string | null, string | null>;
|
||||
memento: JSONColumnTypeNullable<ParsedMemento>;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
}
|
||||
|
|
@ -474,21 +472,13 @@ export interface CastedMatchesInfo {
|
|||
}
|
||||
|
||||
export interface Tournament {
|
||||
settings: ColumnType<TournamentSettings, string, string>;
|
||||
settings: JSONColumnType<TournamentSettings>;
|
||||
id: GeneratedAlways<number>;
|
||||
mapPickingStyle: TournamentMapPickingStyle;
|
||||
/** Maps prepared ahead of time for rounds. Follows settings.bracketProgression order. Null in the spot if not defined yet for that bracket. */
|
||||
preparedMaps: ColumnType<
|
||||
(PreparedMaps | null)[] | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
castTwitchAccounts: ColumnType<string[] | null, string | null, string | null>;
|
||||
castedMatchesInfo: ColumnType<
|
||||
CastedMatchesInfo | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
preparedMaps: JSONColumnTypeNullable<(PreparedMaps | null)[]>;
|
||||
castTwitchAccounts: JSONColumnTypeNullable<string[]>;
|
||||
castedMatchesInfo: JSONColumnTypeNullable<CastedMatchesInfo>;
|
||||
rules: string | null;
|
||||
/** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */
|
||||
parentTournamentId: number | null;
|
||||
|
|
@ -548,8 +538,8 @@ export interface TournamentMatch {
|
|||
groupId: number;
|
||||
id: GeneratedAlways<number>;
|
||||
number: number;
|
||||
opponentOne: ColumnType<ParticipantResult, string, string>;
|
||||
opponentTwo: ColumnType<ParticipantResult, string, string>;
|
||||
opponentOne: JSONColumnType<ParticipantResult>;
|
||||
opponentTwo: JSONColumnType<ParticipantResult>;
|
||||
roundId: number;
|
||||
stageId: number;
|
||||
status: (typeof TournamentMatchStatus)[keyof typeof TournamentMatchStatus];
|
||||
|
|
@ -616,7 +606,7 @@ export interface TournamentRound {
|
|||
id: GeneratedAlways<number>;
|
||||
number: number;
|
||||
stageId: number;
|
||||
maps: ColumnType<TournamentRoundMaps | null, string | null, string | null>;
|
||||
maps: JSONColumnTypeNullable<TournamentRoundMaps>;
|
||||
}
|
||||
|
||||
// when updating this also update `defaultBracketSettings` in tournament-utils.ts
|
||||
|
|
@ -682,11 +672,7 @@ export interface TournamentTeam {
|
|||
seed: number | null;
|
||||
/** For formats that have many starting brackets, where should the team start? */
|
||||
startingBracketIdx: number | null;
|
||||
activeRosterUserIds: ColumnType<
|
||||
number[] | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
activeRosterUserIds: JSONColumnTypeNullable<number[]>;
|
||||
tournamentId: number;
|
||||
teamId: number | null;
|
||||
avatarImgId: number | null;
|
||||
|
|
@ -714,7 +700,7 @@ export interface TournamentOrganization {
|
|||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
socials: ColumnType<string[] | null, string | null, string | null>;
|
||||
socials: JSONColumnTypeNullable<string[]>;
|
||||
avatarImgId: number | null;
|
||||
}
|
||||
|
||||
|
|
@ -744,7 +730,7 @@ export interface TournamentOrganizationSeries {
|
|||
organizationId: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
substringMatches: ColumnType<string[], string, string>;
|
||||
substringMatches: JSONColumnType<string[]>;
|
||||
showLeaderboard: Generated<number>;
|
||||
}
|
||||
|
||||
|
|
@ -821,7 +807,7 @@ export interface User {
|
|||
commissionsOpen: Generated<number | null>;
|
||||
commissionText: string | null;
|
||||
country: string | null;
|
||||
css: ColumnType<Record<string, string> | null, string | null, string | null>;
|
||||
css: JSONColumnTypeNullable<Record<string, string>>;
|
||||
customUrl: string | null;
|
||||
discordAvatar: string | null;
|
||||
discordId: string;
|
||||
|
|
@ -849,16 +835,12 @@ export interface User {
|
|||
battlefy: string | null;
|
||||
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
|
||||
youtubeId: string | null;
|
||||
mapModePreferences: ColumnType<
|
||||
UserMapModePreferences | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
mapModePreferences: JSONColumnTypeNullable<UserMapModePreferences>;
|
||||
qWeaponPool: ColumnType<MainWeaponId[] | null, string | null, string | null>;
|
||||
plusSkippedForSeasonNth: number | null;
|
||||
noScreen: Generated<number>;
|
||||
buildSorting: ColumnType<BuildSort[] | null, string | null, string | null>;
|
||||
preferences: ColumnType<UserPreferences | null, string | null, string | null>;
|
||||
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
|
||||
preferences: JSONColumnTypeNullable<UserPreferences>;
|
||||
}
|
||||
|
||||
/** Represents User joined with PlusTier table */
|
||||
|
|
@ -946,11 +928,7 @@ export interface ScrimPost {
|
|||
/** Lowest LUTI div accepted */
|
||||
minDiv: number | null;
|
||||
/** Who sees the post */
|
||||
visibility: ColumnType<
|
||||
AssociationVisibility | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
visibility: JSONColumnTypeNullable<AssociationVisibility>;
|
||||
/** Any additional info */
|
||||
text: string | null;
|
||||
/** The key to access the scrim chat, used after scrim is scheduled with another team */
|
||||
|
|
@ -999,11 +977,7 @@ export interface AssociationMember {
|
|||
export interface Notification {
|
||||
id: GeneratedAlways<number>;
|
||||
type: NotificationValue["type"];
|
||||
meta: ColumnType<
|
||||
Record<string, number | string> | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
meta: JSONColumnTypeNullable<Record<string, number | string>>;
|
||||
pictureUrl: string | null;
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
|
@ -1025,7 +999,7 @@ export interface NotificationSubscription {
|
|||
export interface NotificationUserSubscription {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
subscription: ColumnType<NotificationSubscription, string, string>;
|
||||
subscription: JSONColumnType<NotificationSubscription>;
|
||||
}
|
||||
|
||||
export type Tables = { [P in keyof DB]: Selectable<DB[P]> };
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Input } from "~/components/Input";
|
|||
import { Main } from "~/components/Main";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
|
|
@ -144,13 +144,10 @@ function Impersonate() {
|
|||
reloadDocument
|
||||
>
|
||||
<h2>Impersonate user</h2>
|
||||
<div>
|
||||
<label>User to log in as</label>
|
||||
<UserSearch
|
||||
inputName="user"
|
||||
onChange={(newUser) => setUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<UserSearch
|
||||
label="User to log in as"
|
||||
onChange={(newUser) => setUserId(newUser.id)}
|
||||
/>
|
||||
<div className="stack horizontal md">
|
||||
<Button type="submit" disabled={!userId}>
|
||||
Go
|
||||
|
|
@ -182,20 +179,16 @@ function MigrateUser() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Migrate user data</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>Old user</label>
|
||||
<UserSearch
|
||||
inputName="old-user"
|
||||
onChange={(newUser) => setOldUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>New user</label>
|
||||
<UserSearch
|
||||
inputName="new-user"
|
||||
onChange={(newUser) => setNewUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<UserSearch
|
||||
label="Old user"
|
||||
name="old-user"
|
||||
onChange={(newUser) => setOldUserId(newUser.id)}
|
||||
/>
|
||||
<UserSearch
|
||||
label="New user"
|
||||
name="new-user"
|
||||
onChange={(newUser) => setNewUserId(newUser.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
|
|
@ -218,10 +211,7 @@ function LinkPlayer() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Link player</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div>
|
||||
<label>Player ID</label>
|
||||
<input type="number" name="playerId" />
|
||||
|
|
@ -243,10 +233,7 @@ function GiveArtist() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Add as artist</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="ARTIST" state={fetcher.state}>
|
||||
|
|
@ -264,10 +251,7 @@ function GiveVideoAdder() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Give video adder</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="VIDEO_ADDER" state={fetcher.state}>
|
||||
|
|
@ -284,12 +268,7 @@ function GiveTournamentOrganizer() {
|
|||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Give tournament organizer</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
|
|
@ -310,10 +289,7 @@ function UpdateFriendCode() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Update friend code</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div>
|
||||
<label>Friend code</label>
|
||||
<Input
|
||||
|
|
@ -345,10 +321,7 @@ function ForcePatron() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Force patron</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
|
||||
<div>
|
||||
<label>Tier</label>
|
||||
|
|
@ -384,10 +357,7 @@ function BanUser() {
|
|||
<fetcher.Form className="stack md" method="post">
|
||||
<h2 className="text-warning">Ban user</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
|
||||
<div>
|
||||
<label>Banned till</label>
|
||||
|
|
@ -414,10 +384,7 @@ function UnbanUser() {
|
|||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2 className="text-warning">Unban user</h2>
|
||||
<div>
|
||||
<label>User</label>
|
||||
<UserSearch inputName="user" />
|
||||
</div>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="UNBAN_USER" state={fetcher.state}>
|
||||
Save
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import * as React from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { UnlinkIcon } from "~/components/icons/Unlink";
|
||||
|
|
@ -93,11 +95,17 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
|||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
close={close}
|
||||
className="art__dialog__image-container"
|
||||
closeOnAnyClick
|
||||
<SendouDialog
|
||||
heading={databaseTimestampToDate(art.createdAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
onClose={close}
|
||||
isFullScreen
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
|
|
@ -135,17 +143,15 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
|||
{art.description}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-xxs text-lighter">
|
||||
{databaseTimestampToDate(art.createdAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
<SendouButton
|
||||
variant="destructive"
|
||||
className="mx-auto mt-6"
|
||||
onPress={close}
|
||||
icon={<CrossIcon />}
|
||||
>
|
||||
Close
|
||||
</SendouButton>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import { Combobox } from "~/components/Combobox";
|
|||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -343,7 +343,7 @@ function LinkedUsers() {
|
|||
return (
|
||||
<div key={inputId} className="stack horizontal sm mb-2 items-center">
|
||||
<UserSearch
|
||||
inputName="user"
|
||||
name="user"
|
||||
onChange={(newUser) => {
|
||||
const newUsers = structuredClone(users);
|
||||
newUsers[i] = { ...newUsers[i], userId: newUser.id };
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const meta: MetaFunction = (args) => {
|
|||
});
|
||||
};
|
||||
|
||||
// xxx: 401 page should show same as vod
|
||||
export default function ArtPage() {
|
||||
const { t } = useTranslation(["art", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { SendouDialog } from "~/components/elements/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 };
|
||||
|
||||
|
|
@ -20,20 +20,21 @@ export default function AssociationsNewPage() {
|
|||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog
|
||||
heading={t("scrims:associations.forms.title")}
|
||||
onCloseTo={associationsPage()}
|
||||
>
|
||||
<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>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { Form, useMatches, useOutletContext } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Label } from "~/components/Label";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { Button } from "~/components/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
|
||||
|
|
@ -11,6 +9,8 @@ import { atOrError } from "~/utils/arrays";
|
|||
import type { BadgeDetailsLoaderData } from "../loaders/badges.$id.server";
|
||||
import type { BadgeDetailsContext } from "./badges.$id";
|
||||
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { action } from "../actions/badges.$id.edit.server";
|
||||
export { action };
|
||||
|
||||
|
|
@ -22,26 +22,17 @@ export default function EditBadgePage() {
|
|||
const canManageBadge = useHasPermission(badge, "MANAGE");
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog
|
||||
heading={`Editing winners of ${badge.displayName}`}
|
||||
onCloseTo={atOrError(matches, -2).pathname}
|
||||
isFullScreen
|
||||
>
|
||||
<Form method="post" className="stack md">
|
||||
<div>
|
||||
<h2 className="badges-edit__big-header">
|
||||
Editing winners of {badge.displayName}
|
||||
</h2>
|
||||
<LinkButton
|
||||
to={atOrError(matches, -2).pathname}
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
className="badges-edit__cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
{isStaff ? <Managers data={data} /> : null}
|
||||
{isStaff && canManageBadge ? <Divider className="mt-2" /> : null}
|
||||
{canManageBadge ? <Owners data={data} /> : null}
|
||||
</Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -61,31 +52,23 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
.map((m) => m.id),
|
||||
).length;
|
||||
|
||||
const userIdsToOmitFromCombobox = React.useMemo(() => {
|
||||
return new Set(data.badge.managers.map((m) => m.id));
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className="stack sm">
|
||||
<h3 className="badges-edit__small-header">Managers</h3>
|
||||
<div className="text-center my-4">
|
||||
<Label
|
||||
className="stack vertical items-center"
|
||||
htmlFor="add-new-manager"
|
||||
>
|
||||
Add new manager
|
||||
</Label>
|
||||
<UserSearch
|
||||
id="add-new-manager"
|
||||
className="mx-auto"
|
||||
inputName="new-manager"
|
||||
onChange={(user) => {
|
||||
setManagers([...managers, user]);
|
||||
}}
|
||||
userIdsToOmit={userIdsToOmitFromCombobox}
|
||||
/>
|
||||
</div>
|
||||
<UserSearch
|
||||
key={managers.map((m) => m.id).join("-")}
|
||||
label="Add new manager"
|
||||
className="text-center mx-auto"
|
||||
name="new-manager"
|
||||
onChange={(user) => {
|
||||
if (managers.some((m) => m.id === user.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setManagers([...managers, user]);
|
||||
}}
|
||||
/>
|
||||
<ul className="badges-edit__users-list">
|
||||
{managers.map((manager) => (
|
||||
<li key={manager.id}>
|
||||
|
|
@ -107,16 +90,16 @@ function Managers({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
name="managerIds"
|
||||
value={JSON.stringify(managers.map((m) => m.id))}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="tiny"
|
||||
className="badges-edit__submit-button"
|
||||
disabled={amountOfChanges === 0}
|
||||
name="_action"
|
||||
value="MANAGERS"
|
||||
>
|
||||
{submitButtonText(amountOfChanges)}
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={amountOfChanges === 0}
|
||||
name="_action"
|
||||
value="MANAGERS"
|
||||
>
|
||||
{submitButtonText(amountOfChanges)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -139,30 +122,25 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
<div className="stack md">
|
||||
<div className="stack sm">
|
||||
<h3 className="badges-edit__small-header">Owners</h3>
|
||||
<div className="text-center my-4">
|
||||
<Label className="stack items-center" htmlFor="add-new-owner">
|
||||
Add new owner
|
||||
</Label>
|
||||
<UserSearch
|
||||
id="add-new-owner"
|
||||
className="mx-auto"
|
||||
inputName="new-owner"
|
||||
key={userInputKey}
|
||||
onChange={(user) => {
|
||||
setOwners((previousOwners) => {
|
||||
const existingOwner = previousOwners.find(
|
||||
(o) => o.id === user.id,
|
||||
<UserSearch
|
||||
label="Add new owner"
|
||||
className="text-center mx-auto"
|
||||
name="new-owner"
|
||||
key={userInputKey}
|
||||
onChange={(user) => {
|
||||
setOwners((previousOwners) => {
|
||||
const existingOwner = previousOwners.find(
|
||||
(o) => o.id === user.id,
|
||||
);
|
||||
if (existingOwner) {
|
||||
return previousOwners.map((o) =>
|
||||
o.id === user.id ? { ...o, count: o.count + 1 } : o,
|
||||
);
|
||||
if (existingOwner) {
|
||||
return previousOwners.map((o) =>
|
||||
o.id === user.id ? { ...o, count: o.count + 1 } : o,
|
||||
);
|
||||
}
|
||||
return [...previousOwners, { count: 1, ...user }];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
return [...previousOwners, { count: 1, ...user }];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ul className="badges-edit__users-list">
|
||||
{owners.map((owner) => (
|
||||
|
|
@ -214,16 +192,16 @@ function Owners({ data }: { data: BadgeDetailsLoaderData }) {
|
|||
name="ownerIds"
|
||||
value={JSON.stringify(countArrayToDuplicatedIdsArray(owners))}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="tiny"
|
||||
className="badges-edit__submit-button"
|
||||
disabled={ownerDifferences.length === 0}
|
||||
name="_action"
|
||||
value="OWNERS"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={ownerDifferences.length === 0}
|
||||
name="_action"
|
||||
value="OWNERS"
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { FormErrors } from "~/components/FormErrors";
|
|||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { CALENDAR_EVENT_RESULT } from "~/constants";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
|
|
@ -277,7 +277,7 @@ function Players({
|
|||
) : (
|
||||
<UserSearch
|
||||
id={formId}
|
||||
inputName="team-player"
|
||||
name="team-player"
|
||||
initialUserId={player.id}
|
||||
onChange={(newUser) => handleInputChange(i, newUser.id)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,23 +12,30 @@ import {
|
|||
badRequestIfFalsy,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { firstCommentActionSchema } from "../plus-suggestions-schemas";
|
||||
import { canSuggestNewUserBE } from "../plus-suggestions-utils";
|
||||
import {
|
||||
canSuggestNewUser,
|
||||
playerAlreadyMember,
|
||||
playerAlreadySuggested,
|
||||
} from "../plus-suggestions-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: firstCommentActionSchema,
|
||||
});
|
||||
|
||||
unauthorizedIfFalsy(user.plusTier && user.plusTier <= data.tier);
|
||||
|
||||
const suggested = badRequestIfFalsy(
|
||||
await UserRepository.findLeanById(data.userId),
|
||||
);
|
||||
|
||||
const user = await requireUser(request);
|
||||
|
||||
const votingMonthYear = rangeToMonthYear(
|
||||
badRequestIfFalsy(nextNonCompletedVoting(new Date())),
|
||||
);
|
||||
|
|
@ -36,13 +43,25 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canSuggestNewUserBE({
|
||||
user,
|
||||
!playerAlreadySuggested({
|
||||
suggestions,
|
||||
suggested,
|
||||
targetPlusTier: data.tier,
|
||||
}),
|
||||
"This user has already been suggested",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!playerAlreadyMember({ suggested, targetPlusTier: data.tier }),
|
||||
"This user is already a member of this tier",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canSuggestNewUser({
|
||||
user,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to make this suggestion",
|
||||
"Can't make a suggestion right now",
|
||||
);
|
||||
|
||||
await PlusSuggestionRepository.create({
|
||||
|
|
|
|||
|
|
@ -131,14 +131,14 @@ function suggestionHasNoOtherComments({
|
|||
throw new Error(`Invalid suggestion id: ${suggestionId}`);
|
||||
}
|
||||
|
||||
interface CanSuggestNewUserFEArgs {
|
||||
interface CanSuggestNewUserArgs {
|
||||
user?: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
suggestions: PlusSuggestionRepository.FindAllByMonthItem[];
|
||||
}
|
||||
export function canSuggestNewUserFE({
|
||||
export function canSuggestNewUser({
|
||||
user,
|
||||
suggestions,
|
||||
}: CanSuggestNewUserFEArgs) {
|
||||
}: CanSuggestNewUserArgs) {
|
||||
const votingActive =
|
||||
process.env.NODE_ENV === "test" ? false : isVotingActive();
|
||||
|
||||
|
|
@ -152,24 +152,6 @@ export function canSuggestNewUserFE({
|
|||
]);
|
||||
}
|
||||
|
||||
interface CanSuggestNewUserBEArgs extends CanSuggestNewUserFEArgs {
|
||||
suggested: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
targetPlusTier: NonNullable<UserWithPlusTier["plusTier"]>;
|
||||
}
|
||||
export function canSuggestNewUserBE({
|
||||
user,
|
||||
suggestions,
|
||||
suggested,
|
||||
targetPlusTier,
|
||||
}: CanSuggestNewUserBEArgs) {
|
||||
return allTruthy([
|
||||
canSuggestNewUserFE({ user, suggestions }),
|
||||
!playerAlreadySuggested({ suggestions, suggested, targetPlusTier }),
|
||||
targetPlusTierIsSmallerOrEqual({ user, targetPlusTier }),
|
||||
!playerAlreadyMember({ suggested, targetPlusTier }),
|
||||
]);
|
||||
}
|
||||
|
||||
function isPlusServerMember(user?: Pick<UserWithPlusTier, "plusTier">) {
|
||||
return Boolean(user?.plusTier);
|
||||
}
|
||||
|
|
@ -177,14 +159,17 @@ function isPlusServerMember(user?: Pick<UserWithPlusTier, "plusTier">) {
|
|||
export function playerAlreadyMember({
|
||||
suggested,
|
||||
targetPlusTier,
|
||||
}: Pick<CanSuggestNewUserBEArgs, "suggested" | "targetPlusTier">) {
|
||||
}: {
|
||||
suggested: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
targetPlusTier: NonNullable<UserWithPlusTier["plusTier"]>;
|
||||
}) {
|
||||
return suggested.plusTier && suggested.plusTier <= targetPlusTier;
|
||||
}
|
||||
|
||||
function hasUserSuggestedThisMonth({
|
||||
user,
|
||||
suggestions,
|
||||
}: Pick<CanSuggestNewUserFEArgs, "user" | "suggestions">) {
|
||||
}: Pick<CanSuggestNewUserArgs, "user" | "suggestions">) {
|
||||
return suggestions.some(
|
||||
(suggestion) => suggestion.suggestions[0].author.id === user?.id,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Form, useMatches, useParams } from "@remix-run/react";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { PlUS_SUGGESTION_COMMENT_MAX_LENGTH } from "~/constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
|
|
@ -42,26 +42,18 @@ export default function PlusCommentModalPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog
|
||||
heading={`${userBeingCommented.suggested.username}'s +${tierSuggestedTo} suggestion`}
|
||||
onCloseTo={plusSuggestionPage()}
|
||||
>
|
||||
<Form method="post" className="stack md">
|
||||
<input type="hidden" name="tier" value={tierSuggestedTo} />
|
||||
<input type="hidden" name="suggestedId" value={targetUserId} />
|
||||
<h2 className="plus__modal-title">
|
||||
{userBeingCommented.suggested.username}'s +{tierSuggestedTo}{" "}
|
||||
suggestion
|
||||
</h2>
|
||||
<CommentTextarea maxLength={PlUS_SUGGESTION_COMMENT_MAX_LENGTH} />
|
||||
<div className="plus__modal-buttons">
|
||||
<div>
|
||||
<Button type="submit">Submit</Button>
|
||||
<LinkButton
|
||||
to={plusSuggestionPage()}
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,18 @@
|
|||
import { Form, useMatches } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import {
|
||||
PLUS_TIERS,
|
||||
PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH,
|
||||
} from "~/constants";
|
||||
import type { UserWithPlusTier } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import {
|
||||
canSuggestNewUserFE,
|
||||
playerAlreadyMember,
|
||||
playerAlreadySuggested,
|
||||
} from "../plus-suggestions-utils";
|
||||
import { canSuggestNewUser } from "../plus-suggestions-utils";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
|
||||
import { action } from "../actions/plus.suggestions.new.server";
|
||||
|
|
@ -29,11 +22,6 @@ export default function PlusNewSuggestionModalPage() {
|
|||
const user = useUser();
|
||||
const matches = useMatches();
|
||||
const data = atOrError(matches, -2).data as PlusSuggestionsLoaderData;
|
||||
const [selectedUser, setSelectedUser] = React.useState<{
|
||||
/** User id */
|
||||
value: string;
|
||||
plusTier: number | null;
|
||||
} | null>(null);
|
||||
|
||||
const tierOptions = PLUS_TIERS.filter((tier) => {
|
||||
// user will be redirected anyway
|
||||
|
|
@ -47,7 +35,7 @@ export default function PlusNewSuggestionModalPage() {
|
|||
|
||||
if (
|
||||
!data.suggestions ||
|
||||
!canSuggestNewUserFE({
|
||||
!canSuggestNewUser({
|
||||
user,
|
||||
suggestions: data.suggestions,
|
||||
}) ||
|
||||
|
|
@ -56,18 +44,12 @@ export default function PlusNewSuggestionModalPage() {
|
|||
return <Redirect to={plusSuggestionPage({ showAlert: true })} />;
|
||||
}
|
||||
|
||||
const selectedUserErrorMessage = getSelectedUserErrorMessage({
|
||||
suggested: selectedUser
|
||||
? { id: Number(selectedUser.value), plusTier: selectedUser.plusTier }
|
||||
: undefined,
|
||||
suggestions: data.suggestions,
|
||||
targetPlusTier,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog
|
||||
heading="Adding a new suggestion"
|
||||
onCloseTo={plusSuggestionPage()}
|
||||
>
|
||||
<Form method="post" className="stack md">
|
||||
<h2 className="plus__modal-title">Adding a new suggestion</h2>
|
||||
<div>
|
||||
<label htmlFor="tier">Tier</label>
|
||||
<select
|
||||
|
|
@ -84,66 +66,16 @@ export default function PlusNewSuggestionModalPage() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="user">Suggested user</label>
|
||||
<UserSearch
|
||||
inputName="userId"
|
||||
onChange={(user) =>
|
||||
setSelectedUser({
|
||||
plusTier: user.plusTier,
|
||||
value: String(user.id),
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
{selectedUserErrorMessage ? (
|
||||
<FormMessage type="error">{selectedUserErrorMessage}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
<UserSearch name="userId" label="Suggested user" isRequired />
|
||||
<CommentTextarea maxLength={PlUS_SUGGESTION_FIRST_COMMENT_MAX_LENGTH} />
|
||||
<div className="plus__modal-buttons">
|
||||
<SubmitButton disabled={Boolean(selectedUserErrorMessage)}>
|
||||
Submit
|
||||
</SubmitButton>
|
||||
<LinkButton
|
||||
to={plusSuggestionPage()}
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
<div>
|
||||
<SubmitButton>Submit</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getSelectedUserErrorMessage({
|
||||
suggestions,
|
||||
targetPlusTier,
|
||||
suggested,
|
||||
}: {
|
||||
suggestions: NonNullable<PlusSuggestionsLoaderData["suggestions"]>;
|
||||
targetPlusTier: number;
|
||||
suggested?: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
}) {
|
||||
if (!suggested) return;
|
||||
|
||||
if (
|
||||
playerAlreadyMember({
|
||||
suggested,
|
||||
targetPlusTier,
|
||||
})
|
||||
) {
|
||||
return `This user already has access to +${targetPlusTier}`;
|
||||
}
|
||||
if (playerAlreadySuggested({ targetPlusTier, suggestions, suggested })) {
|
||||
return `This user was already suggested to +${targetPlusTier}`;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export function CommentTextarea({ maxLength }: { maxLength: number }) {
|
||||
const [value, setValue] = React.useState("");
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { userPage } from "~/utils/urls";
|
|||
import {
|
||||
canAddCommentToSuggestionFE,
|
||||
canDeleteComment,
|
||||
canSuggestNewUserFE,
|
||||
canSuggestNewUser,
|
||||
} from "../plus-suggestions-utils";
|
||||
|
||||
import { action } from "../actions/plus.suggestions.server";
|
||||
|
|
@ -84,7 +84,7 @@ export default function PlusSuggestionsPage() {
|
|||
<div className="stack lg">
|
||||
<div
|
||||
className={clsx("plus__top-container", {
|
||||
"content-centered": !canSuggestNewUserFE({
|
||||
"content-centered": !canSuggestNewUser({
|
||||
user,
|
||||
suggestions: data.suggestions,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ 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 { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { SCRIM } from "~/features/scrims/scrims-constants";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
|
|
@ -66,31 +66,26 @@ export function WithFormField({ usersTeams }: FromFormFieldProps) {
|
|||
</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>
|
||||
<UserSearch
|
||||
initialUserId={user!.id}
|
||||
isDisabled
|
||||
label={t("scrims:forms.with.user", { nth: 1 })}
|
||||
/>
|
||||
{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>
|
||||
<UserSearch
|
||||
key={i}
|
||||
initialUserId={userId}
|
||||
onChange={(user) =>
|
||||
onChange({
|
||||
mode: "PICKUP",
|
||||
users: value.users.map((u, j) =>
|
||||
j === i ? user.id : u,
|
||||
),
|
||||
})
|
||||
}
|
||||
isRequired={i < 3}
|
||||
label={t("scrims:forms.with.user", { nth: i + 2 })}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
<FormMessage type="error">
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default function NewScrimPage() {
|
|||
<Main>
|
||||
<MyForm
|
||||
schema={scrimsNewActionSchema}
|
||||
title={t("scrims:forms.title")}
|
||||
heading={t("scrims:forms.title")}
|
||||
defaultValues={{
|
||||
postText: "",
|
||||
at: new Date(),
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ 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 { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { EyeSlashIcon } from "~/components/icons/EyeSlash";
|
||||
|
|
@ -183,10 +183,9 @@ function RequestScrimModal({
|
|||
invariant(post, "Post not found");
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
|
||||
<MyForm
|
||||
schema={newRequestSchema}
|
||||
title={t("scrims:requestModal.title")}
|
||||
defaultValues={{
|
||||
_action: "NEW_REQUEST",
|
||||
scrimPostId: postId,
|
||||
|
|
@ -200,7 +199,6 @@ function RequestScrimModal({
|
|||
) as unknown as number[],
|
||||
},
|
||||
}}
|
||||
handleCancel={close}
|
||||
>
|
||||
<ScrimsDaySeparatedTables posts={[post]} showPopovers={false} />
|
||||
<div className="font-semi-bold text-lighter italic">
|
||||
|
|
@ -212,7 +210,7 @@ function RequestScrimModal({
|
|||
<Divider />
|
||||
<WithFormField usersTeams={data.teams} />
|
||||
</MyForm>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -484,7 +482,6 @@ function ScrimsTable({
|
|||
<FormWithConfirm
|
||||
dialogHeading={t("scrims:deleteModal.title")}
|
||||
submitButtonText={t("common:actions.delete")}
|
||||
cancelButtonText={t("common:actions.nevermind")}
|
||||
fields={[
|
||||
["scrimPostId", post.id],
|
||||
["_action", "DELETE_POST"],
|
||||
|
|
@ -524,7 +521,6 @@ function ScrimsTable({
|
|||
<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"],
|
||||
|
|
@ -643,7 +639,6 @@ function RequestRow({
|
|||
]}
|
||||
submitButtonVariant="primary"
|
||||
submitButtonText={t("common:actions.accept")}
|
||||
cancelButtonVariant="destructive"
|
||||
>
|
||||
<Button size="tiny" className="ml-auto">
|
||||
{t("common:actions.accept")}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useFetcher } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { preferenceEmojiUrl } from "~/utils/urls";
|
||||
|
|
@ -28,19 +26,13 @@ export function AddPrivateNoteDialog({
|
|||
if (!aboutUser) return null;
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<SendouDialog
|
||||
isOpen
|
||||
heading={t("q:privateNote.header", { name: aboutUser.username })}
|
||||
onClose={close}
|
||||
>
|
||||
<fetcher.Form method="post" className="stack md">
|
||||
<input type="hidden" name="targetId" value={aboutUser.id} />
|
||||
<div className="stack horizontal items-center justify-between">
|
||||
<h2 className="text-md">
|
||||
{t("q:privateNote.header", { name: aboutUser.username })}
|
||||
</h2>
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
icon={<CrossIcon />}
|
||||
onClick={close}
|
||||
/>
|
||||
</div>
|
||||
<Textarea initialValue={aboutUser.privateNote?.text} />
|
||||
<Sentiment initialValue={aboutUser.privateNote?.sentiment} />
|
||||
<div className="stack items-center mt-2">
|
||||
|
|
@ -49,7 +41,7 @@ export function AddPrivateNoteDialog({
|
|||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -777,7 +777,6 @@ function BottomSection({
|
|||
...(!data.groupMemberOf ? [["adminReport", "on"] as const] : []),
|
||||
]}
|
||||
submitButtonText={t("common:actions.cancel")}
|
||||
cancelButtonText={t("common:actions.nevermind")}
|
||||
fetcher={cancelFetcher}
|
||||
>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import * as React from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FriendCodeInput } from "~/components/FriendCodeInput";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -264,17 +264,17 @@ function JoinTeamDialog({
|
|||
invariant(owner, "Owner not found");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
<SendouDialog
|
||||
isOpen={open}
|
||||
close={close}
|
||||
closeOnAnyClick={false}
|
||||
onClose={close}
|
||||
isDismissable
|
||||
className="text-center"
|
||||
>
|
||||
{t("q:front.join.header", {
|
||||
heading={t("q:front.join.header", {
|
||||
members: joinListToNaturalString(members.map((m) => m.username)),
|
||||
})}
|
||||
>
|
||||
<fetcher.Form
|
||||
className="stack horizontal justify-center sm mt-4 flex-wrap"
|
||||
className="stack horizontal justify-center md mt-6 flex-wrap"
|
||||
method="post"
|
||||
>
|
||||
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
|
||||
|
|
@ -289,14 +289,11 @@ function JoinTeamDialog({
|
|||
inviterName: owner.username,
|
||||
})}
|
||||
</SubmitButton>
|
||||
<Button onClick={close} variant="destructive">
|
||||
{t("q:front.join.refuseAction")}
|
||||
</Button>
|
||||
<FormMessage type="info">
|
||||
{t("q:front.join.joinWithTrustAction.explanation")}
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,14 @@
|
|||
import type { MetaFunction } from "@remix-run/node";
|
||||
import {
|
||||
Form,
|
||||
Link,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import { Form, Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
|
|
@ -157,7 +150,6 @@ export default function TeamSearchPage() {
|
|||
|
||||
function NewTeamDialog() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const user = useUser();
|
||||
const isSupporter = useHasRole("SUPPORTER");
|
||||
|
|
@ -165,8 +157,6 @@ function NewTeamDialog() {
|
|||
|
||||
const isOpen = searchParams.get("new") === "true";
|
||||
|
||||
const close = () => navigate(TEAM_SEARCH_PAGE);
|
||||
|
||||
const canAddNewTeam = () => {
|
||||
if (!user) return false;
|
||||
if (isSupporter) {
|
||||
|
|
@ -186,10 +176,13 @@ function NewTeamDialog() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} close={close} className="text-center">
|
||||
<SendouDialog
|
||||
heading={t("team:newTeam.header")}
|
||||
isOpen={isOpen}
|
||||
onCloseTo={TEAM_SEARCH_PAGE}
|
||||
>
|
||||
<Form method="post" className="stack md">
|
||||
<h2 className="text-sm">{t("team:newTeam.header")}</h2>
|
||||
<div className="team-search__form-input-container">
|
||||
<div className="">
|
||||
<label htmlFor="name">{t("common:forms.name")}</label>
|
||||
<input
|
||||
id="name"
|
||||
|
|
@ -201,13 +194,10 @@ function NewTeamDialog() {
|
|||
/>
|
||||
</div>
|
||||
<FormErrors namespace="team" />
|
||||
<div className="stack horizontal md justify-center mt-4">
|
||||
<div className="mt-2">
|
||||
<SubmitButton>{t("common:actions.create")}</SubmitButton>
|
||||
<Button variant="destructive" onClick={close}>
|
||||
{t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,6 @@
|
|||
background-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
.team-search__form-input-container {
|
||||
text-align: left;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.team__banner {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
|
|
|
|||
|
|
@ -228,7 +228,10 @@ export function SwissBracket({
|
|||
})}
|
||||
</div>
|
||||
{teamWithBye ? (
|
||||
<div className="text-xs text-lighter font-semi-bold">
|
||||
<div
|
||||
className="text-xs text-lighter font-semi-bold"
|
||||
data-testid="bye-team"
|
||||
>
|
||||
BYE: {teamWithBye.name}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { ModeImage, StageImage } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
||||
import type { TournamentRoundMaps } from "~/db/tables";
|
||||
|
|
@ -271,7 +271,12 @@ export function BracketMapListDialog({
|
|||
!eliminationTeamCount;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} close={close} className="map-list-dialog__dialog">
|
||||
<SendouDialog
|
||||
heading={`Maplist selection (${bracket.name})`}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
isFullScreen
|
||||
>
|
||||
<fetcher.Form method="post" className="map-list-dialog__container">
|
||||
<input type="hidden" name="bracketIdx" value={bracketIdx} />
|
||||
<input
|
||||
|
|
@ -301,7 +306,6 @@ export function BracketMapListDialog({
|
|||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h2 className="text-lg text-center">{bracket.name}</h2>
|
||||
{preparedMaps ? (
|
||||
<div
|
||||
className="text-xs text-center text-lighter"
|
||||
|
|
@ -601,7 +605,7 @@ export function BracketMapListDialog({
|
|||
</>
|
||||
)}
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { TFunction } from "i18next";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { MapIcon } from "~/components/icons/Map";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
|
|
@ -35,10 +35,12 @@ export function OrganizerMatchMapListDialog({
|
|||
let number = 0;
|
||||
return (
|
||||
<>
|
||||
<Dialog isOpen={isOpen} close={() => setIsOpen(false)} className="w-max">
|
||||
<h2 className="text-md">
|
||||
{teamOne.name} vs. {teamTwo.name}
|
||||
</h2>
|
||||
<SendouDialog
|
||||
heading={`${teamOne.name} vs. ${teamTwo.name}`}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
className="w-max"
|
||||
>
|
||||
<div className="mt-2">
|
||||
{nullFilledArray(
|
||||
Math.max(
|
||||
|
|
@ -99,7 +101,7 @@ export function OrganizerMatchMapListDialog({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="tiny"
|
||||
|
|
|
|||
|
|
@ -543,10 +543,6 @@
|
|||
width: 280px;
|
||||
}
|
||||
|
||||
.map-list-dialog__dialog {
|
||||
width: 64rem;
|
||||
}
|
||||
|
||||
.map-list-dialog__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default function TournamentOrganizationEditPage() {
|
|||
return (
|
||||
<Main>
|
||||
<MyForm
|
||||
title={t("org:edit.form.title")}
|
||||
heading={t("org:edit.form.title")}
|
||||
schema={organizationEditSchema}
|
||||
defaultValues={{
|
||||
name: data.organization.name,
|
||||
|
|
|
|||
|
|
@ -300,6 +300,11 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
case "ADD_STAFF": {
|
||||
validateIsTournamentAdmin();
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.ctx.staff.every((staff) => staff.id !== data.userId),
|
||||
"User is already a staff member",
|
||||
);
|
||||
|
||||
await TournamentRepository.addStaff({
|
||||
role: data.role,
|
||||
tournamentId: tournament.ctx.id,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import { Label } from "~/components/Label";
|
|||
import { containerClassName } from "~/components/Main";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import { USER } from "~/constants";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
|
|
@ -27,7 +28,6 @@ import {
|
|||
tournamentEditPage,
|
||||
tournamentPage,
|
||||
} from "~/utils/urls";
|
||||
import { Dialog } from "../../../components/Dialog";
|
||||
import { BracketProgressionSelector } from "../../calendar/components/BracketProgressionSelector";
|
||||
import { useTournament } from "./to.$id";
|
||||
|
||||
|
|
@ -323,8 +323,7 @@ function TeamActions() {
|
|||
) : null}
|
||||
{selectedAction.inputs.includes("USER") ? (
|
||||
<div>
|
||||
<label htmlFor="user">User</label>
|
||||
<UserSearch inputName="userId" id="user" />
|
||||
<UserSearch name="userId" label="User" />
|
||||
</div>
|
||||
) : null}
|
||||
{selectedAction.inputs.includes("BRACKET") ? (
|
||||
|
|
@ -421,39 +420,29 @@ function CastTwitchAccounts() {
|
|||
|
||||
function StaffAdder() {
|
||||
const fetcher = useFetcher();
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack sm">
|
||||
<div className="stack horizontal sm flex-wrap items-end">
|
||||
<div className="stack horizontal sm flex-wrap items-start">
|
||||
<div>
|
||||
<Label htmlFor="staff-user">New staffer</Label>
|
||||
<UserSearch
|
||||
inputName="userId"
|
||||
id="staff-user"
|
||||
required
|
||||
userIdsToOmit={
|
||||
new Set([
|
||||
tournament.ctx.author.id,
|
||||
...tournament.ctx.staff.map((s) => s.id),
|
||||
])
|
||||
}
|
||||
/>
|
||||
<UserSearch name="userId" label="New staffer" isRequired />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="staff-role">Role</Label>
|
||||
<select name="role" id="staff-role" className="w-max">
|
||||
<option value="ORGANIZER">Organizer</option>
|
||||
<option value="STREAMER">Streamer</option>
|
||||
</select>
|
||||
<div className="stack horizontal sm items-end">
|
||||
<div>
|
||||
<Label htmlFor="staff-role">Role</Label>
|
||||
<select name="role" id="staff-role" className="w-max">
|
||||
<option value="ORGANIZER">Organizer</option>
|
||||
<option value="STREAMER">Streamer</option>
|
||||
</select>
|
||||
</div>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
_action="ADD_STAFF"
|
||||
testId="add-staff-button"
|
||||
>
|
||||
Add
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
_action="ADD_STAFF"
|
||||
testId="add-staff-button"
|
||||
>
|
||||
Add
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Organizer has same permissions as you expect adding/removing staff,
|
||||
|
|
@ -782,7 +771,11 @@ function BracketProgressionEditDialog({ close }: { close: () => void }) {
|
|||
.map((bracket) => bracket.idx);
|
||||
|
||||
return (
|
||||
<Dialog isOpen className="w-max">
|
||||
<SendouDialog
|
||||
isFullScreen
|
||||
onClose={close}
|
||||
heading="Editing bracket progression"
|
||||
>
|
||||
<fetcher.Form method="post">
|
||||
<BracketProgressionSelector
|
||||
initialBrackets={Progression.validatedBracketsToInputFormat(
|
||||
|
|
@ -802,11 +795,8 @@ function BracketProgressionEditDialog({ close }: { close: () => void }) {
|
|||
>
|
||||
Save changes
|
||||
</SubmitButton>
|
||||
<Button variant="destructive" onClick={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ import * as React from "react";
|
|||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Catcher } from "~/components/Catcher";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Draggable } from "~/components/Draggable";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { Table } from "~/components/Table";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { useTimeoutState } from "~/hooks/useTimeoutState";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
|
@ -241,9 +241,13 @@ function StartingBracketDialog() {
|
|||
>
|
||||
Set starting brackets
|
||||
</Button>
|
||||
<Dialog isOpen={isOpen} close={() => setIsOpen(false)} className="w-max">
|
||||
<SendouDialog
|
||||
heading="Setting starting brackets"
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
isFullScreen
|
||||
>
|
||||
<fetcher.Form className="stack lg items-center" method="post">
|
||||
<h2 className="text-lg self-start">Setting starting brackets</h2>
|
||||
<div>
|
||||
{startingBrackets.map((bracket) => {
|
||||
const teamCount = teamStartingBrackets.filter(
|
||||
|
|
@ -323,7 +327,7 @@ function StartingBracketDialog() {
|
|||
Save
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import * as React from "react";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { BuildCard } from "~/components/BuildCard";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
|
||||
import { LockIcon } from "~/components/icons/Lock";
|
||||
import { SortIcon } from "~/components/icons/Sort";
|
||||
|
|
@ -225,14 +225,13 @@ function ChangeSortingDialog({ close }: { close: () => void }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Dialog isOpen close={close}>
|
||||
<SendouDialog heading={t("user:builds.sorting.header")} onClose={close}>
|
||||
<fetcher.Form method="post">
|
||||
<input
|
||||
type="hidden"
|
||||
name="buildSorting"
|
||||
value={JSON.stringify(buildSorting.filter(Boolean))}
|
||||
/>
|
||||
<h2 className="text-lg">{t("user:builds.sorting.header")}</h2>
|
||||
<div className="stack lg">
|
||||
<div className="stack md">
|
||||
<FormMessage type="info">
|
||||
|
|
@ -282,17 +281,14 @@ function ChangeSortingDialog({ close }: { close: () => void }) {
|
|||
})}
|
||||
</div>
|
||||
|
||||
<div className="stack sm horizontal justify-center">
|
||||
<div>
|
||||
<SubmitButton _action="UPDATE_SORTING">
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
<Button variant="destructive" onClick={close}>
|
||||
{t("common:actions.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { WeaponCombobox } from "~/components/Combobox";
|
|||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { UserSearch } from "~/components/UserSearch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { AddFieldButton } from "~/components/form/AddFieldButton";
|
||||
import { RemoveFieldButton } from "~/components/form/RemoveFieldButton";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
|
@ -59,7 +59,7 @@ export default function NewVodPage() {
|
|||
return (
|
||||
<Main halfWidth>
|
||||
<MyForm
|
||||
title={
|
||||
heading={
|
||||
data.vodToEdit
|
||||
? t("vods:forms.title.edit")
|
||||
: t("vods:forms.title.create")
|
||||
|
|
@ -164,34 +164,25 @@ function PovFormField() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal md items-center mb-1">
|
||||
<Label required htmlFor="pov" className="mb-0">
|
||||
{t("vods:forms.title.pov")}
|
||||
</Label>
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
onClick={toggleInputType}
|
||||
className="outline-theme"
|
||||
>
|
||||
{asPlainInput
|
||||
? t("calendar:forms.team.player.addAsUser")
|
||||
: t("calendar:forms.team.player.addAsText")}
|
||||
</Button>
|
||||
</div>
|
||||
{asPlainInput ? (
|
||||
<input
|
||||
id="pov"
|
||||
value={value.name ?? ""}
|
||||
onChange={(e) => {
|
||||
onChange({ type: "NAME", name: e.target.value });
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<>
|
||||
<Label required htmlFor="pov">
|
||||
{t("vods:forms.title.pov")}
|
||||
</Label>
|
||||
<input
|
||||
id="pov"
|
||||
value={value.name ?? ""}
|
||||
onChange={(e) => {
|
||||
onChange({ type: "NAME", name: e.target.value });
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<UserSearch
|
||||
id="pov"
|
||||
inputName="team-player"
|
||||
label={t("vods:forms.title.pov")}
|
||||
isRequired
|
||||
name="team-player"
|
||||
initialUserId={value.userId}
|
||||
onChange={(newUser) =>
|
||||
onChange({
|
||||
|
|
@ -202,6 +193,16 @@ function PovFormField() {
|
|||
onBlur={onBlur}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
onClick={toggleInputType}
|
||||
className="outline-theme mt-2"
|
||||
>
|
||||
{asPlainInput
|
||||
? t("calendar:forms.team.player.addAsUser")
|
||||
: t("calendar:forms.team.player.addAsText")}
|
||||
</Button>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
|
|
|
|||
16
app/root.tsx
16
app/root.tsx
|
|
@ -26,6 +26,7 @@ import { RouterProvider } from "react-aria-components";
|
|||
import { ErrorBoundary as ClientErrorBoundary } from "react-error-boundary";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { NavigateOptions } from "react-router-dom";
|
||||
import { useDebounce } from "react-use";
|
||||
import { useChangeLanguage } from "remix-i18next/react";
|
||||
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
|
||||
import { NOTIFICATIONS } from "~/features/notifications/notifications-contants";
|
||||
|
|
@ -234,10 +235,17 @@ function useTriggerToasts() {
|
|||
function useLoadingIndicator() {
|
||||
const transition = useNavigation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (transition.state === "loading") NProgress.start();
|
||||
if (transition.state === "idle") NProgress.done();
|
||||
}, [transition.state]);
|
||||
useDebounce(
|
||||
() => {
|
||||
if (transition.state === "loading") {
|
||||
NProgress.start();
|
||||
} else if (transition.state === "idle") {
|
||||
NProgress.done();
|
||||
}
|
||||
},
|
||||
250,
|
||||
[transition.state],
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: this should be an array if we can figure out how to make Typescript
|
||||
|
|
|
|||
|
|
@ -114,10 +114,6 @@
|
|||
padding-inline-end: 5px;
|
||||
}
|
||||
|
||||
.badges-edit__submit-button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.badges-search__input {
|
||||
height: 40px !important;
|
||||
margin: 0 auto;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ body {
|
|||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
*:focus:not(:focus-visible) {
|
||||
*:focus:not(:focus-visible):not([data-focus-visible]) {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
|
|
@ -375,38 +375,6 @@ abbr[title] {
|
|||
cursor: help;
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: min(90%, 24rem);
|
||||
border: 0;
|
||||
border-radius: var(--rounded);
|
||||
margin: auto;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: hsla(237deg 98% 1% / 70%);
|
||||
}
|
||||
|
||||
@supports ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
|
||||
dialog::backdrop {
|
||||
-webkit-backdrop-filter: blur(7px) brightness(70%);
|
||||
backdrop-filter: blur(7px) brightness(70%);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
dialog[open],
|
||||
dialog::backdrop {
|
||||
animation: show 500ms ease;
|
||||
}
|
||||
|
||||
@keyframes show {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button-text-paragraph {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
|
|
|
|||
|
|
@ -353,16 +353,13 @@
|
|||
.layout__overlay-nav__dialog {
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout__overlay-nav__dialog[open],
|
||||
.layout__overlay-nav__dialog::backdrop {
|
||||
animation: none;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.layout__overlay-nav__close-button {
|
||||
margin-inline-start: auto;
|
||||
margin-block-end: var(--s-4);
|
||||
margin-inline-end: var(--s-4);
|
||||
}
|
||||
|
||||
.layout__overlay-nav__close-button > svg {
|
||||
|
|
@ -400,7 +397,8 @@
|
|||
}
|
||||
|
||||
.layout__overlay-nav__dialog {
|
||||
padding-block: var(--s-12);
|
||||
padding-block: var(--s-12) !important;
|
||||
padding-inline: 0 !important;
|
||||
}
|
||||
|
||||
.layout__breadcrumb-container > a {
|
||||
|
|
|
|||
|
|
@ -72,10 +72,6 @@
|
|||
display: inline;
|
||||
}
|
||||
|
||||
.plus__modal-title {
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.plus__modal-select {
|
||||
max-width: 6rem;
|
||||
}
|
||||
|
|
@ -85,12 +81,6 @@
|
|||
resize: none;
|
||||
}
|
||||
|
||||
.plus__modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.plus-voting__container {
|
||||
max-width: 32rem;
|
||||
margin-inline: auto;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { sql } from "kysely";
|
||||
import { type ColumnType, sql } from "kysely";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
export const COMMON_USER_FIELDS = [
|
||||
|
|
@ -32,3 +32,6 @@ export function unJsonify<T>(value: T) {
|
|||
|
||||
return `\\${value}`;
|
||||
}
|
||||
|
||||
export type JSONColumnTypeNullable<SelectType extends object | null> =
|
||||
ColumnType<SelectType | null, string | null, string | null>;
|
||||
|
|
|
|||
|
|
@ -24,12 +24,15 @@ export async function selectUser({
|
|||
userName: string;
|
||||
labelName: string;
|
||||
}) {
|
||||
const combobox = page.getByLabel(labelName);
|
||||
await expect(combobox).not.toBeDisabled();
|
||||
const comboboxButton = page.getByLabel(labelName);
|
||||
const searchInput = page.getByTestId("user-search-input");
|
||||
const option = page.getByTestId("user-search-item").first();
|
||||
|
||||
await combobox.clear();
|
||||
await combobox.fill(userName);
|
||||
await expect(page.getByTestId("combobox-option-0")).toBeVisible();
|
||||
await expect(comboboxButton).not.toBeDisabled();
|
||||
|
||||
await comboboxButton.click();
|
||||
await searchInput.fill(userName);
|
||||
await expect(option).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ test.describe("Badges", () => {
|
|||
labelName: "Add new owner",
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Submit", exact: true }).click();
|
||||
|
||||
await impersonate(page);
|
||||
await navigate({
|
||||
|
|
|
|||
|
|
@ -881,7 +881,7 @@ test.describe("Tournament bracket", () => {
|
|||
await page.getByTestId("reset-round-button").click();
|
||||
await page.getByTestId("confirm-button").click();
|
||||
await page.getByTestId("start-round-button").click();
|
||||
await expect(page.getByText("BYE")).toBeVisible();
|
||||
await expect(page.getByTestId("bye-team")).toBeVisible();
|
||||
});
|
||||
|
||||
test("prepares maps (including third place match linking)", async ({
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@
|
|||
"forms.description": "Description",
|
||||
"forms.errors.title": "Following errors need to be fixed",
|
||||
"forms.errors.noSearchMatches": "No matches found",
|
||||
"forms.userSearch.placeholder": "Search users by username, profile URL or Discord ID...",
|
||||
"forms.userSearch.noResults": "No users matching your search found",
|
||||
|
||||
"errors.genericReload": "Something went wrong. Try reloading the page.",
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"front.join.joinAction": "Join",
|
||||
"front.join.joinWithTrustAction": "Join & trust {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Trusting a user allows them to add you to groups without an invite link in the future",
|
||||
"front.join.refuseAction": "No thanks",
|
||||
"front.seasonOpen": "Season {{nth}} open",
|
||||
"front.preview": "Preview groups in the queue without joining",
|
||||
"front.preview.explanation": "This feature is only available to Supporter tier (or above) patrons of sendou.ink",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
"front.join.joinAction": "Únete",
|
||||
"front.join.joinWithTrustAction": "Unirte y confiar de {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Confiar de un usuario le permite agregarte a un grupo sin enviar invitación en el futuro",
|
||||
"front.join.refuseAction": "No, gracias",
|
||||
"front.seasonOpen": "Temporada {{nth}} está abierta",
|
||||
"front.preview": "Ver avance de grupos en fila sin unirte",
|
||||
"front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
"front.join.joinAction": "Únete",
|
||||
"front.join.joinWithTrustAction": "Unirte y confiar de {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Confiar de un usuario le permite agregarte a un grupo sin enviar invitación en el futuro",
|
||||
"front.join.refuseAction": "No, gracias",
|
||||
"front.seasonOpen": "Temporada {{nth}} está abierta",
|
||||
"front.preview": "Ver avance de grupos en fila sin unirte",
|
||||
"front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"front.join.joinAction": "Rejoindre",
|
||||
"front.join.joinWithTrustAction": "Rejoindre et faire confiance à {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Faire confiance à cette utilisateur pour vous ajouter à des groupes sans liens d'invitations",
|
||||
"front.join.refuseAction": "Non merci",
|
||||
"front.seasonOpen": "La saison {{nth}} est ouverte",
|
||||
"front.preview": "Regarder qui est dans la queue sans la rejoindre",
|
||||
"front.preview.explanation": "Cette fonctionnalité n'est disponible que pour les utilisateurs de niveau Supporter (ou supérieur) sur le patreon de sendou.ink.",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"front.join.joinAction": "Unisciti",
|
||||
"front.join.joinWithTrustAction": "Unisciti e aggiungere {{inviterName}} agli utenti fidati",
|
||||
"front.join.joinWithTrustAction.explanation": "Fidarsi di un utente permette a loro di aggiungerti ad un gruppo senza link d'invito nel futuro",
|
||||
"front.join.refuseAction": "No grazie",
|
||||
"front.seasonOpen": "Stagione {{nth}} aperta",
|
||||
"front.preview": "Visualizza gruppi nella coda prima di unirti",
|
||||
"front.preview.explanation": "Questa funzione è disponibile solo per iscritti al Patreon di sendou.ink di tier Supporter o più",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
"front.join.joinAction": "ジョイン",
|
||||
"front.join.joinWithTrustAction": "{{inviterName}}をジョイン、信用する",
|
||||
"front.join.joinWithTrustAction.explanation": "ユーザーを信用するとこれからインバイトリンク無しでグループにあなたを追加できます。",
|
||||
"front.join.refuseAction": "結構です",
|
||||
"front.seasonOpen": "シーズン{{nth}}開幕",
|
||||
"front.preview": "ジョインせず列にいるグループを見る",
|
||||
"front.preview.explanation": "この機能はサポーター(あるいはそれ以上)の支援者のみ使えます。",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@
|
|||
"front.join.joinAction": "Entrar",
|
||||
"front.join.joinWithTrustAction": "Entrar e confiar no(a) {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Confiar em usuário permite que ele(a) adicione você aos grupos sem um link de convite no futuro",
|
||||
"front.join.refuseAction": "Não, obrigado(a)",
|
||||
"front.seasonOpen": "Temporada {{nth}} aberta",
|
||||
"front.preview": "Pré-visualizar grupos na fila sem entrar",
|
||||
"front.preview.explanation": "Essa função está disponível apenas para patronos (ou patronesses) da tier Supporter (ou acima) do sendou.ink",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@
|
|||
"front.join.joinAction": "Присоедениться",
|
||||
"front.join.joinWithTrustAction": "Присоедениться и довериться {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "Доверенный пользователь имеет право в будущем добавить вас в группу без ссылки на приглашение",
|
||||
"front.join.refuseAction": "Нет",
|
||||
"front.seasonOpen": "Сезон {{nth}} открыт",
|
||||
|
||||
"settings.maps.header": "Арены и режимы",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@
|
|||
"front.join.joinAction": "加入",
|
||||
"front.join.joinWithTrustAction": "加入并信任 {{inviterName}}",
|
||||
"front.join.joinWithTrustAction.explanation": "信任一名用户能允许对方不通过邀请链接就能直接将您加入小队",
|
||||
"front.join.refuseAction": "不用了",
|
||||
"front.seasonOpen": "赛季 {{nth}} 进行中",
|
||||
"front.preview": "预览当前匹配中的队伍",
|
||||
"front.preview.explanation": "该功能仅对sendou.ink的patron支持者开放",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user