Merge branch 'main' into css-rework-sidenav

This commit is contained in:
Kalle
2026-01-18 21:12:24 +02:00
345 changed files with 21280 additions and 5208 deletions

View File

@@ -6,5 +6,8 @@
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "beans prime" }] }
]
},
"enabledPlugins": {
"code-review@claude-plugins-official": true
}
}

View File

@@ -22,6 +22,22 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Start MinIO
run: docker compose up -d minio
- name: Wait for MinIO to be ready
run: |
for i in {1..30}; do
if curl -sf http://127.0.0.1:9000/minio/health/live; then
echo "MinIO is ready"
exit 0
fi
echo "Waiting for MinIO... ($i/30)"
sleep 2
done
echo "MinIO failed to start"
exit 1
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
@@ -36,6 +52,10 @@ jobs:
- name: Run E2E tests
run: npm run test:e2e
- name: Stop MinIO
if: always()
run: docker compose down
- uses: actions/upload-artifact@v4
if: failure()
with:

1
.gitignore vendored
View File

@@ -30,5 +30,6 @@ dump
# Vitest auto-captured failure screenshots (numbered, without browser info)
# Real baselines have pattern: *-chromium-darwin.png
**/__screenshots__/**/*-[0-9].png
.e2e-minio-started
notepad.txt

View File

@@ -13,7 +13,7 @@
- `npm run test:unit:browser` runs all unit tests and browser tests
- `npm run test:e2e` runs all e2e tests
- `npm run test:e2e:flaky-detect` runs all e2e tests and repeats each 10 times
- `npm run i18n:sync` syncs translation jsons with English and should always be run after adding new text to an English translation file
- `npm run i18n:sync` syncs translation jsons with English
## Typescript
@@ -74,3 +74,10 @@
## Testing in Chrome
- some pages need authentication, you should impersonate "Sendou" user which can be done on the /admin page
## i18n
- by default everything should be translated via i18next
- some a11y labels or text that should not normally be encountered by user (example given, error message by server) can be english
- before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json)
- add only English translation and use `npm run i18n:sync` to initialize other jsons with empty string ready for translators

View File

@@ -7,6 +7,7 @@ import {
} from "react-router";
import { useLocation } from "react-use";
import { useUser } from "~/features/auth/core/user";
import { getSessionId } from "~/utils/session-id";
import {
ERROR_GIRL_IMAGE_PATH,
LOG_IN_URL,
@@ -52,10 +53,11 @@ export function Catcher() {
}
if (!isRouteErrorResponse(error)) {
const sessionId = getSessionId();
const errorText = (() => {
if (!(error instanceof Error)) return;
return `Time: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`;
return `Session ID: ${sessionId}\nTime: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`;
})();
return (
@@ -124,12 +126,15 @@ export function Catcher() {
<h2>Error {error.status}</h2>
<GetHelp />
<div className="text-sm text-lighter font-semi-bold">
Please include the message below if any and an explanation on what
you were doing:
Please include the session ID and message below if any and an
explanation on what you were doing:
</div>
{error.data ? (
<pre>{JSON.stringify(JSON.parse(error.data), null, 2)}</pre>
) : null}
<pre>
Session ID: {getSessionId()}
{error.data
? `\n${JSON.stringify(JSON.parse(error.data), null, 2)}`
: null}
</pre>
</Main>
);
}

View File

@@ -1,38 +0,0 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type * as React from "react";
export function Draggable({
id,
disabled,
liClassName,
children,
testId,
}: {
id: number;
disabled: boolean;
liClassName: string;
children: React.ReactNode;
testId?: string;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className={liClassName}
style={style}
ref={setNodeRef}
data-testid={testId}
{...listeners}
{...attributes}
>
{children}
</li>
);
}

View File

@@ -11,3 +11,7 @@
font-size: var(--fonts-xs);
margin-block-start: var(--label-margin);
}
.noMargin {
margin-block-start: 0;
}

View File

@@ -6,15 +6,21 @@ export function FormMessage({
children,
type,
className,
spaced = true,
id,
}: {
children: React.ReactNode;
type: "error" | "info";
className?: string;
spaced?: boolean;
id?: string;
}) {
return (
<div
id={id}
className={clsx(
{ [styles.info]: type === "info", [styles.error]: type === "error" },
{ [styles.noMargin]: !spaced },
className,
)}
>

View File

@@ -1,3 +1,7 @@
.selectWidthWider {
--select-width: 100%;
}
.item {
display: flex;
gap: var(--s-2);

View File

@@ -46,6 +46,8 @@ interface WeaponSelectProps<
isRequired?: boolean;
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
quickSelectWeaponsIds?: Array<MainWeaponId>;
isDisabled?: boolean;
placeholder?: string;
}
export function WeaponSelect<
@@ -62,11 +64,20 @@ export function WeaponSelect<
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
isDisabled,
placeholder,
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
const { t } = useTranslation(["common"]);
const selectedWeaponId: MainWeaponId | null =
typeof value === "number"
? (value as MainWeaponId)
: value && typeof value === "object" && value.type === "MAIN"
? (value.id as MainWeaponId)
: null;
const { items, filterValue, setFilterValue } = useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
});
const filter = useWeaponFilter();
@@ -97,9 +108,10 @@ export function WeaponSelect<
aria-label={
!label ? t("common:forms.weaponSearch.placeholder") : undefined
}
isDisabled={isDisabled}
items={items}
label={label}
placeholder={t("common:forms.weaponSearch.placeholder")}
placeholder={placeholder ?? t("common:forms.weaponSearch.placeholder")}
search={{
placeholder: t("common:forms.weaponSearch.search.placeholder"),
}}
@@ -215,9 +227,11 @@ function useWeaponFilter() {
function useWeaponItems({
includeSubSpecial,
quickSelectWeaponsIds,
selectedWeaponId,
}: {
includeSubSpecial: boolean | undefined;
quickSelectWeaponsIds?: Array<MainWeaponId>;
selectedWeaponId?: MainWeaponId | null;
}) {
const items = useAllWeaponCategories(includeSubSpecial);
const [filterValue, setFilterValue] = React.useState("");
@@ -227,6 +241,11 @@ function useWeaponItems({
filterValue === "" && quickSelectWeaponsIds?.length;
if (showQuickSelectWeapons) {
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
if (typeof selectedWeaponId === "number") {
weaponIdsToInclude.add(selectedWeaponId);
}
const quickSelectCategory = {
idx: 0,
key: "quick-select" as const,
@@ -238,7 +257,7 @@ function useWeaponItems({
.filter((val) => val !== null),
)
.filter((item) =>
quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId),
weaponIdsToInclude.has(item.weapon.id as MainWeaponId),
)
.sort((a, b) => {
const aIdx = quickSelectWeaponsIds.indexOf(

View File

@@ -4,18 +4,20 @@ import { SendouFieldMessage } from "~/components/elements/FieldMessage";
export function SendouBottomTexts({
bottomText,
errorText,
errorId,
}: {
bottomText?: string;
errorText?: string;
errorId?: string;
}) {
return (
<>
{errorText ? (
<SendouFieldError>{errorText}</SendouFieldError>
<SendouFieldError id={errorId}>{errorText}</SendouFieldError>
) : (
<SendouFieldError />
)}
{bottomText && !errorText ? (
{bottomText ? (
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
) : null}
</>

View File

@@ -12,6 +12,7 @@ import {
} from "react-aria-components";
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouCalendar } from "~/components/elements/Calendar";
import { useIsMounted } from "~/hooks/useIsMounted";
import styles from "./DatePicker.module.css";
import { SendouLabel } from "./Label";
@@ -20,15 +21,33 @@ interface SendouDatePickerProps<T extends DateValue>
label: string;
bottomText?: string;
errorText?: string;
errorId?: string;
}
export function SendouDatePicker<T extends DateValue>({
label,
errorText,
errorId,
bottomText,
isRequired,
...rest
}: SendouDatePickerProps<T>) {
const isMounted = useIsMounted();
if (!isMounted) {
return (
<div>
<SendouLabel required={isRequired}>{label}</SendouLabel>
<input type="text" disabled />
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
</div>
);
}
return (
<ReactAriaDatePicker
{...rest}
@@ -46,7 +65,11 @@ export function SendouDatePicker<T extends DateValue>({
<Calendar className={styles.icon} />
</Button>
</Group>
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
<SendouBottomTexts
bottomText={bottomText}
errorText={errorText}
errorId={errorId}
/>
<Popover>
<Dialog>
<SendouCalendar />

View File

@@ -1,8 +1,14 @@
import { FieldError as ReactAriaFieldError } from "react-aria-components";
export function SendouFieldError({ children }: { children?: React.ReactNode }) {
export function SendouFieldError({
children,
id,
}: {
children?: React.ReactNode;
id?: string;
}) {
return (
<ReactAriaFieldError className="error-message">
<ReactAriaFieldError className="error-message" id={id}>
{children}
</ReactAriaFieldError>
);

View File

@@ -83,7 +83,7 @@ export const UserSearch = React.forwardRef(function UserSearch<
placeholder=""
selectedKey={selectedKey}
onSelectionChange={onSelectionChange as (key: Key | null) => void}
aria-label="User search"
{...(label ? {} : { "aria-label": "User search" })}
{...rest}
>
{label ? (

View File

@@ -1,21 +0,0 @@
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "../elements/Button";
export function AddFieldButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation(["common"]);
return (
<SendouButton
icon={<Plus />}
aria-label="Add form field"
size="small"
variant="minimal"
onPress={onClick}
className="self-start"
data-testid="add-field-button"
>
{t("common:actions.add")}
</SendouButton>
);
}

View File

@@ -1,88 +0,0 @@
import type { CalendarDateTime } from "@internationalized/date";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { dateToDateValue, dayMonthYearToDateValue } from "../../utils/dates";
import type { DayMonthYear } from "../../utils/zod";
import { SendouDatePicker } from "../elements/DatePicker";
export function DateFormField<T extends FieldValues>({
label,
name,
bottomText,
required,
granularity = "day",
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
required?: boolean;
granularity?: "day" | "minute";
}) {
const methods = useFormContext();
return (
<Controller
name={name}
control={methods.control}
render={({
field: { name, value, onChange, onBlur /*, ref*/ }, // TODO: figure out where ref goes (to focus on error) and put it there
fieldState: { invalid, error },
}) => {
const getValue = () => {
const originalValue = value as DayMonthYear | Date | null;
if (!originalValue) return null;
if (originalValue instanceof Date) {
return dateToDateValue(originalValue);
}
return dayMonthYearToDateValue(originalValue as DayMonthYear);
};
return (
<SendouDatePicker
label={label}
granularity={granularity}
isRequired={required}
errorText={error?.message as string | undefined}
value={getValue()}
isInvalid={invalid}
name={name}
onBlur={onBlur}
onChange={(value) => {
if (value) {
if (granularity === "minute") {
onChange(
new Date(
value.year,
value.month - 1,
value.day,
(value as CalendarDateTime).hour,
(value as CalendarDateTime).minute,
),
);
} else {
onChange({
day: value.day,
month: value.month - 1,
year: value.year,
});
}
}
if (!value) {
onChange(null);
}
}}
bottomText={bottomText}
/>
);
}}
/>
);
}

View File

@@ -1,25 +0,0 @@
import type * as React from "react";
import { RemoveFieldButton } from "./RemoveFieldButton";
export function FormFieldset({
title,
children,
onRemove,
}: {
title: string;
children: React.ReactNode;
onRemove: () => void;
}) {
return (
<fieldset className="w-min">
<legend>{title}</legend>
<div className="stack sm">
{children}
<div className="mt-4 stack items-center">
<RemoveFieldButton onClick={onRemove} />
</div>
</div>
</fieldset>
);
}

View File

@@ -1,50 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
export function InputFormField<T extends FieldValues>({
label,
name,
bottomText,
placeholder,
required,
type,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
placeholder?: string;
required?: boolean;
type?: React.HTMLInputTypeAttribute;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
<input
id={id}
placeholder={placeholder}
type={type}
{...methods.register(name)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -1,126 +0,0 @@
import clsx from "clsx";
import * as React from "react";
import {
Controller,
type FieldPath,
type FieldValues,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
interface InputGroupFormFieldProps<T extends FieldValues> {
label: string;
name: FieldPath<T>;
bottomText?: string;
direction?: "horizontal" | "vertical";
type: "checkbox" | "radio";
values: Array<{
label: string;
value: string;
}>;
}
export function InputGroupFormField<T extends FieldValues>({
label,
name,
bottomText,
values,
type,
direction = "vertical",
}: InputGroupFormFieldProps<T>) {
const methods = useFormContext();
return (
<Controller
name={name}
control={methods.control}
render={({
field: { name, value, onChange, ref },
fieldState: { error },
}) => {
const handleCheckboxChange =
(name: string) => (newChecked: boolean) => {
const newValue = newChecked
? [...(value || []), name]
: value?.filter((v: string) => v !== name);
onChange(newValue);
};
const handleRadioChange = (name: string) => () => {
onChange(name);
};
return (
<div>
<fieldset
className={clsx("stack sm", {
"horizontal md": direction === "horizontal",
})}
ref={ref}
>
<legend>{label}</legend>
{values.map((checkbox) => {
const isChecked = value?.includes(checkbox.value);
return (
<GroupInput
key={checkbox.value}
type={type}
name={name}
checked={isChecked}
onChange={
type === "checkbox"
? handleCheckboxChange(checkbox.value)
: handleRadioChange(checkbox.value)
}
>
{checkbox.label}
</GroupInput>
);
})}
</fieldset>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}}
/>
);
}
function GroupInput({
children,
name,
checked,
onChange,
type,
}: {
children: React.ReactNode;
name: string;
checked: boolean;
onChange: (newChecked: boolean) => void;
type: "checkbox" | "radio";
}) {
const id = React.useId();
return (
<div className="stack horizontal sm items-center">
<input
type={type}
id={id}
name={name}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<label htmlFor={id} className="mb-0">
{children}
</label>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { Trash } from "lucide-react";
import { SendouButton } from "../elements/Button";
export function RemoveFieldButton({ onClick }: { onClick: () => void }) {
return (
<SendouButton
icon={<Trash />}
aria-label="Remove form field"
size="small"
variant="minimal-destructive"
onPress={onClick}
/>
);
}

View File

@@ -1,49 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
export function SelectFormField<T extends FieldValues>({
label,
name,
values,
bottomText,
required,
}: {
label: string;
name: FieldPath<T>;
values: Array<{ value: string | number; label: string }>;
bottomText?: string;
required?: boolean;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id} required={required}>
{label}
</Label>
<select {...methods.register(name)} id={id}>
{values.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -1,80 +0,0 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import * as React from "react";
import { type DefaultValues, FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import type { z } from "zod";
import { logger } from "~/utils/logger";
import type { ActionError } from "~/utils/remix.server";
import { LinkButton } from "../elements/Button";
import { SubmitButton } from "../SubmitButton";
export function SendouForm<T extends z.ZodTypeAny>({
schema,
defaultValues,
heading,
children,
cancelLink,
submitButtonTestId,
}: {
schema: T;
defaultValues?: DefaultValues<z.infer<T>>;
heading?: string;
children: React.ReactNode;
cancelLink?: string;
submitButtonTestId?: string;
}) {
const { t } = useTranslation(["common"]);
const fetcher = useFetcher<any>();
const methods = useForm({
resolver: standardSchemaResolver(schema as any),
defaultValues,
});
if (methods.formState.isSubmitted && methods.formState.errors) {
logger.error(methods.formState.errors);
}
React.useEffect(() => {
if (!fetcher.data?.isError) return;
const error = fetcher.data as ActionError;
methods.setError(error.field as any, {
message: error.msg,
});
}, [fetcher.data, methods.setError]);
const onSubmit = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(values as Parameters<typeof fetcher.submit>[0], {
method: "post",
encType: "application/json",
}),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
{heading ? <h1 className="text-lg">{heading}</h1> : null}
{children}
<div className="stack horizontal lg justify-between mt-6 w-full">
<SubmitButton state={fetcher.state} testId={submitButtonTestId}>
{t("common:actions.submit")}
</SubmitButton>
{cancelLink ? (
<LinkButton
variant="minimal-destructive"
to={cancelLink}
size="small"
>
{t("common:actions.cancel")}
</LinkButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
);
}

View File

@@ -1,46 +0,0 @@
import * as React from "react";
import {
type FieldPath,
type FieldValues,
get,
useFormContext,
useWatch,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
export function TextAreaFormField<T extends FieldValues>({
label,
name,
bottomText,
maxLength,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
maxLength: number;
}) {
const methods = useFormContext();
const value = useWatch({ name }) ?? "";
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label
htmlFor={id}
valueLimits={{ current: value.length, max: maxLength }}
>
{label}
</Label>
<textarea id={id} {...methods.register(name)} />
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -1,81 +0,0 @@
import {
type FieldPath,
type FieldValues,
useFieldArray,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { AddFieldButton } from "./AddFieldButton";
import { RemoveFieldButton } from "./RemoveFieldButton";
export function TextArrayFormField<T extends FieldValues>({
label,
name,
bottomText,
/** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */
format = "plain",
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
format?: "plain" | "object";
}) {
const {
register,
formState: { errors },
clearErrors,
} = useFormContext();
const { fields, append, remove } = useFieldArray({
name,
});
const rootError = errors[name]?.root;
return (
<div>
<Label>{label}</Label>
<div className="stack md">
{fields.map((field, index) => {
// @ts-expect-error
const error = errors[name]?.[index]?.value;
return (
<div key={field.id}>
<div className="stack horizontal md">
<input
{...register(
format === "plain"
? `${name}.${index}`
: `${name}.${index}.value`,
)}
/>
<RemoveFieldButton
onClick={() => {
remove(index);
clearErrors(`${name}.root`);
}}
/>
</div>
{error && (
<FormMessage type="error">
{error.message as string}
</FormMessage>
)}
</div>
);
})}
<AddFieldButton
// @ts-expect-error
onClick={() => append(format === "plain" ? "" : { value: "" })}
/>
{rootError && (
<FormMessage type="error">{rootError.message as string}</FormMessage>
)}
{bottomText && !rootError ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import * as React from "react";
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { SendouSwitch } from "../elements/Switch";
export function ToggleFormField<T extends FieldValues>({
label,
name,
bottomText,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div>
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
render={({ field: { value, onChange } }) => (
<SendouSwitch
id={id}
isSelected={value ?? false}
onChange={onChange}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -1,47 +0,0 @@
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { UserSearch } from "../elements/UserSearch";
export function UserSearchFormField<T extends FieldValues>({
label,
name,
bottomText,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
}) {
const methods = useFormContext();
const error = get(methods.formState.errors, name);
return (
<div>
<Controller
control={methods.control}
name={name}
render={({ field: { onChange, onBlur, value, ref } }) => (
<UserSearch
onChange={(newUser) => onChange(newUser?.id)}
initialUserId={value}
onBlur={onBlur}
ref={ref}
label={label}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { faker } from "@faker-js/faker";
import { add, sub } from "date-fns";
import { nanoid } from "nanoid";
import * as R from "remeda";
import { db, sql } from "~/db/sql";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
@@ -176,10 +177,12 @@ const basicSeeds = (variation?: SeedVariation | null) => [
nzapUser,
users,
fixAdminId,
makeArtists,
adminUserWeaponPool,
userProfiles,
userMapModePreferences,
userQWeaponPool,
seedingSkills,
lastMonthsVoting,
syncPlusTiers,
lastMonthSuggestions,
@@ -237,6 +240,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
associations,
notifications,
friendships,
liveStreams,
];
export async function seed(variation?: SeedVariation | null) {
@@ -264,6 +268,8 @@ function wipeDB() {
"GroupMatchMap",
"GroupMatch",
"Group",
"TaggedArt",
"ArtTag",
"ArtUserMetadata",
"Art",
"UnvalidatedUserSubmittedImage",
@@ -301,6 +307,8 @@ function wipeDB() {
"TournamentBadgeOwner",
"BadgeManager",
"TournamentOrganization",
"SeedingSkill",
"LiveStream",
];
for (const table of tablesToDelete) {
@@ -349,6 +357,14 @@ function makeAdminTournamentOrganizer() {
.run();
}
function makeArtists() {
sql
.prepare(
`update "User" set "isArtist" = 1 where id in (${ADMIN_ID}, ${NZAP_TEST_ID})`,
)
.run();
}
function adminUserWeaponPool() {
for (const [i, weaponSplId] of [200, 1100, 2000, 4000].entries()) {
sql
@@ -548,6 +564,38 @@ async function userQWeaponPool() {
}
}
function seedingSkills() {
const users = sql.prepare('SELECT id FROM "User" LIMIT 500').all() as {
id: number;
}[];
for (const { id: userId } of users) {
if (faker.number.float() < 0.7) {
const mu = faker.number.float({ min: 22, max: 45 });
const sigma = faker.number.float({ min: 4, max: 8 });
const ordinal = mu - 3 * sigma;
sql
.prepare(
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'RANKED', ?, ?, ?)`,
)
.run(userId, mu, sigma, ordinal);
}
if (faker.number.float() < 0.5) {
const mu = faker.number.float({ min: 22, max: 42 });
const sigma = faker.number.float({ min: 4, max: 8 });
const ordinal = mu - 3 * sigma;
sql
.prepare(
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'UNRANKED', ?, ?, ?)`,
)
.run(userId, mu, sigma, ordinal);
}
}
}
function fakeUser(usedNames: Set<string>) {
return () => ({
discordAvatar: null,
@@ -1352,13 +1400,15 @@ function calendarEventWithToToolsTeams(
"name",
"createdAt",
"tournamentId",
"inviteCode"
"inviteCode",
"seed"
) values (
$id,
$name,
$createdAt,
$tournamentId,
$inviteCode
$inviteCode,
$seed
)
`,
)
@@ -1368,6 +1418,7 @@ function calendarEventWithToToolsTeams(
createdAt: dateToDatabaseTimestamp(new Date()),
tournamentId,
inviteCode: shortNanoid(),
seed: id,
});
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
@@ -2755,3 +2806,70 @@ async function friendships() {
}
}
}
function liveStreams() {
const userIds = userIdsInAscendingOrderById();
// Add deterministic streams for E2E testing
// Users 6 and 7 are in ITZ tournament team 102
const deterministicStreams = [
{ userId: 6, viewerCount: 150, twitch: "test_player_stream_1" },
{ userId: 7, viewerCount: 75, twitch: "test_player_stream_2" },
// Cast-only stream (user 100 is not in ITZ tournament teams)
{ userId: 100, viewerCount: 500, twitch: "test_cast_stream" },
];
for (const stream of deterministicStreams) {
sql
.prepare(
`
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
`,
)
.run({
userId: stream.userId,
viewerCount: stream.viewerCount,
thumbnailUrl: "https://picsum.photos/320/180",
twitch: stream.twitch,
});
}
const streamingUserIds = [
...userIds.slice(3, 20),
...userIds.slice(40, 50),
...userIds.slice(100, 110),
].filter((id) => !deterministicStreams.some((s) => s.userId === id));
const shuffledStreamers = faker.helpers.shuffle(streamingUserIds);
const selectedStreamers = shuffledStreamers.slice(0, 17);
for (const userId of selectedStreamers) {
const viewerCount = faker.helpers.weightedArrayElement([
{ value: faker.number.int({ min: 5, max: 30 }), weight: 5 },
{ value: faker.number.int({ min: 31, max: 100 }), weight: 3 },
{ value: faker.number.int({ min: 101, max: 500 }), weight: 2 },
{ value: faker.number.int({ min: 501, max: 2000 }), weight: 1 },
]);
const thumbnailUrl = faker.image.urlPicsumPhotos({
width: 320,
height: 180,
});
const twitch = `fake_${nanoid()}`.toLowerCase();
sql
.prepare(
`
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
`,
)
.run({
userId,
viewerCount,
thumbnailUrl,
twitch,
});
}
}

View File

@@ -514,6 +514,16 @@ export interface Tournament {
parentTournamentId: number | null;
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
isFinalized: Generated<DBBoolean>;
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
seedingSnapshot: JSONColumnTypeNullable<SeedingSnapshot>;
}
export interface SeedingSnapshot {
savedAt: number;
teams: Array<{
teamId: number;
members: Array<{ userId: number; username: string }>;
}>;
}
export interface PreparedMaps {
@@ -989,6 +999,14 @@ export interface ApiToken {
createdAt: GeneratedAlways<number>;
}
export interface LiveStream {
id: GeneratedAlways<number>;
userId: number | null;
viewerCount: number;
thumbnailUrl: string;
twitch: string | null;
}
export interface BanLog {
id: GeneratedAlways<number>;
userId: number;
@@ -1163,6 +1181,7 @@ export interface DB {
AllTeamMember: TeamMember;
ApiToken: ApiToken;
Art: Art;
LiveStream: LiveStream;
ArtTag: ArtTag;
ArtUserMetadata: ArtUserMetadata;
TaggedArt: TaggedArt;

View File

@@ -4,6 +4,15 @@ import { I18nextProvider } from "react-i18next";
import { HydratedRouter } from "react-router/dom";
import { i18nLoader } from "./modules/i18n/loader";
import { logger } from "./utils/logger";
import { getSessionId } from "./utils/session-id";
const originalFetch = window.fetch;
window.fetch = (input, init) => {
const sessionId = getSessionId();
const headers = new Headers(init?.headers);
headers.set("Sendou-Session-Id", sessionId);
return originalFetch(input, { ...init, headers });
};
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {

View File

@@ -9,7 +9,12 @@ import { type EntryContext, ServerRouter } from "react-router";
import { config } from "~/modules/i18n/config"; // your i18n configuration file
import { i18next } from "~/modules/i18n/i18next.server";
import { resources } from "./modules/i18n/resources.server";
import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server";
import {
daily,
everyHourAt00,
everyHourAt30,
everyTwoMinutes,
} from "./routines/list.server";
import { logger } from "./utils/logger";
// Reject/cancel all pending promises after 5 seconds
@@ -103,8 +108,19 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") {
await routine.run();
}
});
cron.schedule("*/2 * * * *", async () => {
for (const routine of everyTwoMinutes) {
await routine.run();
}
});
}
process.on("unhandledRejection", (reason: string, p: Promise<any>) => {
logger.error("Unhandled Rejection at:", p, "reason:", reason);
});
// wrapper so we get request id shown in the server logs
export function handleError(error: unknown) {
logger.error(error);
}

View File

@@ -309,13 +309,11 @@ describe("Account migration", () => {
it("two accounts with teams results in an error", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 1,
isMainTeam: true,
});
await TeamRepository.create({
customUrl: "team-2",
name: "Team 2",
ownerUserId: 2,
isMainTeam: true,
@@ -335,7 +333,6 @@ describe("Account migration", () => {
it("deletes past team membership status of the new user", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 2,
isMainTeam: true,
@@ -354,7 +351,6 @@ describe("Account migration", () => {
it("handles old user member of the same team as new user (old user has left the team, new user current)", async () => {
await TeamRepository.create({
customUrl: "team-1",
name: "Team 1",
ownerUserId: 2,
isMainTeam: true,

View File

@@ -9,13 +9,13 @@ import { refreshBannedCache } from "~/features/ban/core/banned.server";
import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import { cache } from "~/utils/cache.server";
import { logger } from "~/utils/logger";
import { parseRequestPayload } from "~/utils/remix.server";
const E2E_SEEDS_DIR = "e2e/seeds";
const seedSchema = z.object({
variation: z.enum(SEED_VARIATIONS).nullish(),
source: z.enum(["e2e"]).nullish(),
});
export type SeedVariation = NonNullable<
@@ -27,7 +27,7 @@ export const action: ActionFunction = async ({ request }) => {
throw new Response(null, { status: 400 });
}
const { variation } = await parseRequestPayload({
const { variation, source } = await parseRequestPayload({
request,
schema: seedSchema,
});
@@ -38,16 +38,14 @@ export const action: ActionFunction = async ({ request }) => {
`db-seed-${variationName}.sqlite3`,
);
if (!fs.existsSync(preSeededDbPath)) {
// Fall back to slow seed if pre-seeded db doesn't exist
logger.warn(
`Pre-seeded database not found for variation "${variationName}", falling back to seeding via code.`,
);
const { seed } = await import("~/db/seed");
await seed(variation);
} else {
const usePreSeeded = source === "e2e" && fs.existsSync(preSeededDbPath);
if (usePreSeeded) {
restoreFromPreSeeded(preSeededDbPath);
adjustSeedDatesToCurrent(variationName);
} else {
const { seed } = await import("~/db/seed");
await seed(variation);
}
clearAllTournamentDataCache();
@@ -60,6 +58,9 @@ export const action: ActionFunction = async ({ request }) => {
const REG_OPEN_TOURNAMENT_IDS = [1, 3];
const SEED_REFERENCE_TIMESTAMP = 1767440151;
// TODO: do this cleaner
function adjustSeedDatesToCurrent(variation: SeedVariation) {
const halfAnHourFromNow = Math.floor((Date.now() + 1000 * 60 * 30) / 1000);
const oneHourAgo = Math.floor((Date.now() - 1000 * 60 * 60) / 1000);
@@ -88,6 +89,23 @@ function adjustSeedDatesToCurrent(variation: SeedVariation) {
.run(now, now);
sql.prepare(`UPDATE "GroupLike" SET createdAt = ?`).run(now);
const scrimTimeOffset = now - SEED_REFERENCE_TIMESTAMP;
sql
.prepare(
`UPDATE "ScrimPost" SET "at" = "at" + ?, "createdAt" = "createdAt" + ?`,
)
.run(scrimTimeOffset, scrimTimeOffset);
sql
.prepare(
`UPDATE "ScrimPost" SET "rangeEnd" = "rangeEnd" + ? WHERE "rangeEnd" IS NOT NULL`,
)
.run(scrimTimeOffset);
sql
.prepare(
`UPDATE "ScrimPostRequest" SET "at" = "at" + ? WHERE "at" IS NOT NULL`,
)
.run(scrimTimeOffset);
}
function restoreFromPreSeeded(sourcePath: string) {

View File

@@ -0,0 +1,45 @@
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
const url = new URL(request.url);
const isApiRoute = url.pathname.startsWith("/api/");
if (!isApiRoute) {
return next();
}
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
const response = await next();
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
};

View File

@@ -1,4 +1,3 @@
import { cors } from "remix-utils/cors";
import * as ApiRepository from "~/features/api/ApiRepository.server";
async function loadApiTokensCache() {
@@ -23,12 +22,3 @@ export function requireBearerAuth(req: Request) {
throw new Response("Invalid token", { status: 401 });
}
}
export async function handleOptionsRequest(req: Request) {
if (req.method === "OPTIONS") {
throw await cors(req, new Response("OK", { status: 204 }), {
origin: "*",
credentials: true,
});
}
}

View File

@@ -1,5 +1,4 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import {
@@ -8,10 +7,7 @@ import {
weekNumberToDate,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCalendarWeekResponse } from "../schema";
const paramsSchema = z.object({
@@ -20,7 +16,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { week, year } = parseParams({ params, schema: paramsSchema });
@@ -39,7 +34,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
: null,
}));
return await cors(request, Response.json(result));
return Response.json(result);
};
function fetchEventsOfWeek(args: { week: number; year: number }) {

View File

@@ -1,15 +1,11 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentOrganizationResponse } from "../schema";
const paramsSchema = z.object({
@@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({ params, schema: paramsSchema });
@@ -75,5 +70,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
const paramsSchema = z.object({
@@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { userId } = parseParams({
@@ -29,5 +24,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
matchId: current?.matchId ?? null,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
const paramsSchema = z.object({
@@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { matchId } = parseParams({
@@ -69,6 +64,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
points: null,
})),
teamAlpha: {
id: match.groupAlpha.id,
score: score[0],
players: match.groupAlpha.members.map((member) => ({
userId: member.id,
@@ -76,6 +72,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
},
teamBravo: {
id: match.groupBravo.id,
score: score[1],
players: match.groupBravo.members.map((member) => ({
userId: member.id,
@@ -84,5 +81,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
},
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTeamResponse } from "../schema";
const paramsSchema = z.object({
@@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id: teamId } = parseParams({ params, schema: paramsSchema });
@@ -48,5 +43,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
teamPageUrl: `https://sendou.ink/t/${team.customUrl}`,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,6 +1,5 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
@@ -11,10 +10,7 @@ import { i18next } from "~/modules/i18n/i18next.server";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentMatchResponse } from "../schema";
const paramsSchema = z.object({
@@ -22,7 +18,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["game-misc"]);
@@ -181,5 +176,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
roundName: roundNameWithoutMatchIdentifier ?? null,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketStandingsResponse } from "../schema";
const paramsSchema = z.object({
@@ -16,7 +12,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id, bidx } = parseParams({ params, schema: paramsSchema });
@@ -37,5 +32,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,14 +1,10 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentBracketResponse } from "../schema";
const paramsSchema = z.object({
@@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id, bidx } = parseParams({ params, schema: paramsSchema });
@@ -51,7 +46,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
},
};
return await cors(request, Response.json(result));
return Response.json(result);
};
function teams(bracket: Bracket) {

View File

@@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetCastedTournamentMatchesResponse } from "../schema";
const paramsSchema = z.object({
@@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({
@@ -47,5 +42,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})) ?? [],
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,13 +1,9 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentPlayersResponse } from "../schema";
const paramsSchema = z.object({
@@ -15,7 +11,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({
@@ -26,5 +21,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const participants: GetTournamentPlayersResponse =
await TournamentMatchRepository.userParticipationByTournamentId(id);
return cors(request, Response.json(participants));
return Response.json(participants);
};

View File

@@ -1,6 +1,5 @@
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
@@ -11,10 +10,7 @@ import { databaseTimestampToDate } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentTeamsResponse } from "../schema";
const paramsSchema = z.object({
@@ -22,7 +18,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["game-misc"]);
@@ -170,7 +165,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
};
});
return await cors(request, Response.json(result));
return Response.json(result);
};
function toSeedingPowerSP(ordinals: (number | null)[]) {

View File

@@ -1,15 +1,11 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetTournamentResponse } from "../schema";
const paramsSchema = z.object({
@@ -17,7 +13,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const { id } = parseParams({ params, schema: paramsSchema });
@@ -84,5 +79,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
isFinalized: Boolean(tournament.isFinalized),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,18 +1,14 @@
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod/v4";
import { identifierToUserIdQuery } from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { handleOptionsRequest } from "../api-public-utils.server";
import type { GetUserIdsResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfFalsy(
@@ -27,5 +23,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
customUrl: user.customUrl,
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -1,6 +1,5 @@
import { jsonArrayFrom } from "kysely/helpers/sqlite";
import type { LoaderFunctionArgs } from "react-router";
import { cors } from "remix-utils/cors";
import { z } from "zod";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
@@ -8,10 +7,7 @@ import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { safeNumberParse } from "~/utils/number";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import {
handleOptionsRequest,
requireBearerAuth,
} from "../api-public-utils.server";
import { requireBearerAuth } from "../api-public-utils.server";
import type { GetUserResponse } from "../schema";
const paramsSchema = z.object({
@@ -19,7 +15,6 @@ const paramsSchema = z.object({
});
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
await handleOptionsRequest(request);
requireBearerAuth(request);
const t = await i18next.getFixedT("en", ["weapons"]);
@@ -146,5 +141,5 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
})),
};
return await cors(request, Response.json(result));
return Response.json(result);
};

View File

@@ -121,6 +121,7 @@ export interface GetSendouqMatchResponse {
}
type SendouqMatchTeam = {
id: number;
score: number;
players: Array<SendouqMatchPlayer>;
};

View File

@@ -1,5 +1,4 @@
import type { FileUpload } from "@remix-run/form-data-parser";
import { parseFormData as parseMultipartFormData } from "@remix-run/form-data-parser";
import { nanoid } from "nanoid";
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
@@ -14,6 +13,7 @@ import {
errorToastIfFalsy,
parseFormData,
parseRequestPayload,
safeParseMultipartFormData,
} from "~/utils/remix.server";
import { userArtPage } from "~/utils/urls";
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
@@ -86,7 +86,12 @@ export const action: ActionFunction = async ({ request }) => {
return null;
};
const formData = await parseMultipartFormData(request, uploadHandler);
const formData = await safeParseMultipartFormData(
request,
// 5MB
{ maxFileSize: 5 * 1024 * 1024 },
uploadHandler,
);
const imgSrc = formData.get("img") as string | null;
invariant(imgSrc);

View File

@@ -2,6 +2,8 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { LimitReachedError } from "~/utils/errors";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
@@ -137,6 +139,22 @@ export function insert({ userId, ...associationArgs }: InsertArgs) {
.insertInto("AssociationMember")
.values({ userId, associationId: association.id, role: "ADMIN" })
.execute();
const { count, patronTier } = await trx
.selectFrom("AssociationMember")
.innerJoin("User", "User.id", "AssociationMember.userId")
.select((eb) => [eb.fn.countAll<number>().as("count"), "User.patronTier"])
.where("AssociationMember.userId", "=", userId)
.executeTakeFirstOrThrow();
const maxCount =
(patronTier ?? 0) >= 2
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
if (count > maxCount) {
throw new LimitReachedError("Max amount of associations reached");
}
});
}

View File

@@ -1,36 +1,33 @@
import { type ActionFunctionArgs, redirect } from "react-router";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { requireUser } from "~/features/auth/core/user.server";
import { actionError, parseRequestPayload } from "~/utils/remix.server";
import { parseFormData } from "~/form/parse.server";
import { LimitReachedError } from "~/utils/errors";
import { associationsPage } from "~/utils/urls";
import * as AssociationRepository from "../AssociationRepository.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: createNewAssociationSchema,
});
const associationCount = (
await AssociationRepository.findByMemberUserId(user.id)
).actual;
const maxAssociationCount = user.roles.includes("SUPPORTER")
? ASSOCIATION.MAX_COUNT_SUPPORTER
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
if (associationCount.length >= maxAssociationCount) {
return actionError<typeof createNewAssociationSchema>({
msg: `Regular users can only be a member of ${maxAssociationCount} associations (supporters ${ASSOCIATION.MAX_COUNT_SUPPORTER})`,
field: "name",
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
await AssociationRepository.insert({
name: data.name,
userId: user.id,
});
try {
await AssociationRepository.insert({
name: result.data.name,
userId: user.id,
});
} catch (error) {
if (error instanceof LimitReachedError) {
return { fieldErrors: { name: "forms:errors.maxAssociationsReached" } };
}
throw error;
}
return redirect(associationsPage());
};

View File

@@ -1,9 +1,13 @@
import { z } from "zod";
import { _action, id, inviteCode, safeStringSchema } from "~/utils/zod";
import { textFieldRequired } from "~/form/fields";
import { _action, id, inviteCode } from "~/utils/zod";
import { ASSOCIATION } from "./associations-constants";
export const createNewAssociationSchema = z.object({
name: safeStringSchema({ max: 100 }),
name: textFieldRequired({
label: "labels.name",
maxLength: 100,
}),
});
const removeMemberSchema = z.object({

View File

@@ -1,19 +1,15 @@
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
import { SendouForm } from "~/form/SendouForm";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { associationsPage } from "~/utils/urls";
import { action } from "../actions/associations.new.server";
export { action };
type FormFields = z.infer<typeof createNewAssociationSchema>;
export const handle: SendouRouteHandle = {
i18n: "scrims",
i18n: ["scrims"],
};
export default function AssociationsNewPage() {
@@ -24,16 +20,8 @@ export default function AssociationsNewPage() {
heading={t("scrims:associations.forms.title")}
onCloseTo={associationsPage()}
>
<SendouForm
schema={createNewAssociationSchema}
defaultValues={{
name: "",
}}
>
<InputFormField<FormFields>
label={t("scrims:associations.forms.name.title")}
name="name"
/>
<SendouForm schema={createNewAssociationSchema}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
</SendouDialog>
);

View File

@@ -46,23 +46,29 @@ export function BadgesSelector({
</div>
)}
{showSelect ? (
<select
onBlur={onBlur}
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>
{options
.filter((badge) => !selectedBadges.includes(badge.id))
.map((badge) => (
<option key={badge.id} value={badge.id}>
{badge.displayName}
</option>
))}
</select>
options.length === 0 ? (
<div className="text-warning text-xs">
{t("common:badges.selector.noneAvailable")}
</div>
) : (
<select
onBlur={() => onBlur?.()}
onChange={(e) =>
onChange([...selectedBadges, Number(e.target.value)])
}
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
data-testid="badges-selector"
>
<option>{t("common:badges.selector.select")}</option>
{options
.filter((badge) => !selectedBadges.includes(badge.id))
.map((badge) => (
<option key={badge.id} value={badge.id}>
{badge.displayName}
</option>
))}
</select>
)
) : null}
</div>
);

View File

@@ -283,6 +283,10 @@
"displayName": "Not Enough Liter",
"authorDiscordId": "352207524390240257"
},
"cyndaquil": {
"displayName": "Typh's Birthday Bonanza",
"authorDiscordId": "751912670403362836"
},
"daytonaspeedweeks1st": {
"displayName": "Just Daytona 1-5",
"authorDiscordId": "528851510222782474"
@@ -400,7 +404,7 @@
"authorDiscordId": "354880982890971136"
},
"full-auto": {
"displayName": "Full Auto",
"displayName": "Barrel Spinner [Special]",
"authorDiscordId": "534502084134043648"
},
"fullwipe": {
@@ -439,6 +443,14 @@
"displayName": "Togenashi Togeari Subaru Bracket",
"authorDiscordId": "309327923129745409"
},
"gladiatorgold": {
"displayName": "Survival of the S+ Gladiator (Again)",
"authorDiscordId": "751912670403362836"
},
"gladiatorsilver": {
"displayName": "Survival of the S+ Gladiator",
"authorDiscordId": "751912670403362836"
},
"glaze": {
"displayName": "THE GLAZE GAUNTLET",
"authorDiscordId": "528851510222782474"
@@ -508,7 +520,7 @@
"authorDiscordId": "354880982890971136"
},
"ink-torrent": {
"displayName": "Ink Torrent",
"displayName": "Barrel Spinner",
"authorDiscordId": "534502084134043648"
},
"inkpractice": {

View File

@@ -9,12 +9,11 @@ import type {
MainWeaponId,
ModeShort,
} from "~/modules/in-game-lists/types";
import {
weaponIdHasAlts,
weaponIdToArrayWithAlts,
} from "~/modules/in-game-lists/weapon-ids";
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
import { LimitReachedError } from "~/utils/errors";
import invariant from "~/utils/invariant";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { BUILD } from "./builds-constants";
import { sortAbilities } from "./core/ability-sorting.server";
export async function allByUserId(
@@ -94,9 +93,9 @@ interface CreateArgs {
title: TablesInsertable["Build"]["title"];
description: TablesInsertable["Build"]["description"];
modes: Array<ModeShort> | null;
headGearSplId: TablesInsertable["Build"]["headGearSplId"];
clothesGearSplId: TablesInsertable["Build"]["clothesGearSplId"];
shoesGearSplId: TablesInsertable["Build"]["shoesGearSplId"];
headGearSplId: number | null;
clothesGearSplId: number | null;
shoesGearSplId: number | null;
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
abilities: BuildAbilitiesTuple;
private: TablesInsertable["Build"]["private"];
@@ -123,9 +122,9 @@ async function createInTrx({
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
)
: null,
headGearSplId: args.headGearSplId,
clothesGearSplId: args.clothesGearSplId,
shoesGearSplId: args.shoesGearSplId,
headGearSplId: args.headGearSplId ?? -1,
clothesGearSplId: args.clothesGearSplId ?? -1,
shoesGearSplId: args.shoesGearSplId ?? -1,
private: args.private,
})
.returningAll()
@@ -182,7 +181,19 @@ async function createInTrx({
}
export async function create(args: CreateArgs) {
return db.transaction().execute(async (trx) => createInTrx({ args, trx }));
return db.transaction().execute(async (trx) => {
await createInTrx({ args, trx });
const { count } = await trx
.selectFrom("Build")
.select((eb) => eb.fn.countAll<number>().as("count"))
.where("ownerId", "=", args.ownerId)
.executeTakeFirstOrThrow();
if (count > BUILD.MAX_COUNT) {
throw new LimitReachedError("Max amount of builds reached");
}
});
}
export async function update(args: CreateArgs & { id: number }) {
@@ -196,6 +207,16 @@ export function deleteById(id: number) {
return db.deleteFrom("Build").where("id", "=", id).execute();
}
export async function ownerIdById(buildId: number) {
const result = await db
.selectFrom("Build")
.select("ownerId")
.where("id", "=", buildId)
.executeTakeFirstOrThrow();
return result.ownerId;
}
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
return db
.selectFrom("BuildAbility")
@@ -252,8 +273,80 @@ export async function allByWeaponId(
options: { limit: number; sortAbilities?: boolean },
) {
const { limit, sortAbilities: shouldSortAbilities = false } = options;
const weaponIds = weaponIdToArrayWithAlts(weaponId);
let query = db
let rows: Awaited<ReturnType<typeof buildsByWeaponIdQuery>>;
if (weaponIds.length === 1) {
rows = await buildsByWeaponIdQuery(weaponIds[0], limit);
} else {
// For weapons with alts, run separate queries and merge.
// This allows each query to use the covering index for ordering,
// which is ~6x faster than using IN with multiple values.
const allResults = await Promise.all(
weaponIds.map((id) => buildsByWeaponIdQuery(id, limit)),
);
const seenBuildIds = new Set<number>();
type BuildRow = Awaited<ReturnType<typeof buildsByWeaponIdQuery>>[number];
const merged: BuildRow[] = [];
// Merge results maintaining sort order (tier asc, isTop500 desc, updatedAt desc)
// Since each query returns sorted results, we can interleave them
const pointers = allResults.map(() => 0);
while (merged.length < limit) {
let bestIdx = -1;
let bestRow: BuildRow | null = null;
for (let i = 0; i < allResults.length; i++) {
while (
pointers[i] < allResults[i].length &&
seenBuildIds.has(allResults[i][pointers[i]].id)
) {
pointers[i]++;
}
if (pointers[i] >= allResults[i].length) continue;
const row = allResults[i][pointers[i]];
if (
!bestRow ||
row.bwTier < bestRow.bwTier ||
(row.bwTier === bestRow.bwTier &&
row.bwIsTop500 > bestRow.bwIsTop500) ||
(row.bwTier === bestRow.bwTier &&
row.bwIsTop500 === bestRow.bwIsTop500 &&
row.bwUpdatedAt > bestRow.bwUpdatedAt)
) {
bestIdx = i;
bestRow = row;
}
}
if (bestIdx === -1 || !bestRow) break;
seenBuildIds.add(bestRow.id);
merged.push(bestRow);
pointers[bestIdx]++;
}
rows = merged;
}
return rows.map((row) => {
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
return {
...row,
abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities,
};
});
}
function buildsByWeaponIdQuery(weaponSplId: MainWeaponId, limit: number) {
return db
.selectFrom("BuildWeapon")
.innerJoin("Build", "Build.id", "BuildWeapon.buildId")
.leftJoin("PlusTier", "PlusTier.userId", "Build.ownerId")
@@ -268,6 +361,9 @@ export async function allByWeaponId(
"Build.updatedAt",
"Build.private",
"PlusTier.tier as plusTier",
"BuildWeapon.tier as bwTier",
"BuildWeapon.isTop500 as bwIsTop500",
"BuildWeapon.updatedAt as bwUpdatedAt",
withAbilities(eb),
jsonArrayFrom(
eb
@@ -284,26 +380,12 @@ export async function allByWeaponId(
).as("owner"),
])
.where("Build.private", "=", 0)
.where("BuildWeapon.weaponSplId", "in", weaponIdToArrayWithAlts(weaponId))
.where("BuildWeapon.weaponSplId", "=", weaponSplId)
.orderBy("BuildWeapon.tier", "asc")
.orderBy("BuildWeapon.isTop500", "desc")
.orderBy("BuildWeapon.updatedAt", "desc")
.limit(limit);
if (weaponIdHasAlts(weaponId)) {
query = query.groupBy("BuildWeapon.buildId");
}
const rows = await query.execute();
return rows.map((row) => {
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
return {
...row,
abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities,
};
});
.limit(limit)
.execute();
}
function withAbilities(eb: ExpressionBuilder<DB, "Build">) {

View File

@@ -60,10 +60,6 @@ export const PATCHES = [
];
export const BUILD = {
TITLE_MIN_LENGTH: 1,
TITLE_MAX_LENGTH: 50,
DESCRIPTION_MAX_LENGTH: 280,
MAX_WEAPONS_COUNT: 5,
MAX_COUNT: 250,
} as const;

View File

@@ -40,7 +40,6 @@ export const action: ActionFunction = async ({ request }) => {
const data = await parseFormData({
formData,
schema: newCalendarEventActionSchema,
parseAsync: true,
});
const isEditing = Boolean(data.eventToEditId);

View File

@@ -3,6 +3,15 @@ import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import {
array,
checkboxGroup,
numberFieldOptional,
radioGroup,
textFieldOptional,
toggle,
userSearchOptional,
} from "~/form/fields";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
import {
@@ -49,21 +58,102 @@ export const calendarFiltersSearchParamsSchema = z.object({
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
});
const TAGS_TO_OMIT: CalendarEventTag[] = [
"CARDS",
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",
];
const filterTags = CALENDAR_EVENT.TAGS.filter(
(tag) => !TAGS_TO_OMIT.includes(tag),
);
const tagItems = filterTags.map((tag) => ({
label: `options.tag.${tag}` as const,
value: tag,
}));
export const calendarFiltersFormSchema = z
.object({
preferredStartTime: preferredStartTime,
tagsIncluded: z.array(calendarEventTagSchema),
tagsExcluded: z.array(calendarEventTagSchema),
isSendou: z.boolean(),
isRanked: z.boolean(),
orgsIncluded: calendarFiltersPlainStringArr,
orgsExcluded: calendarFiltersPlainStringArr,
authorIdsExcluded: calendarFiltersIdsArr,
games: calendarFilterGamesArr,
preferredVersus: preferredVersus,
modes: modeArr,
modesExact: z.boolean(),
minTeamCount: z.coerce.number().int().nonnegative(),
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
{ label: "modes.TC", value: "TC" },
{ label: "modes.RM", value: "RM" },
{ label: "modes.CB", value: "CB" },
{ label: () => "Salmon Run", value: "SR" },
{ label: () => "Tricolor", value: "TB" },
],
minLength: 1,
}),
modesExact: toggle({
label: "labels.modesExact",
bottomText: "bottomTexts.modesExact",
}),
games: checkboxGroup({
label: "labels.games",
items: [
{ label: "options.game.S1", value: "S1" },
{ label: "options.game.S2", value: "S2" },
{ label: "options.game.S3", value: "S3" },
],
minLength: 1,
}),
preferredVersus: checkboxGroup({
label: "labels.vs",
items: [
{ label: () => "4v4", value: "4v4" },
{ label: () => "3v3", value: "3v3" },
{ label: () => "2v2", value: "2v2" },
{ label: () => "1v1", value: "1v1" },
],
minLength: 1,
}),
preferredStartTime: radioGroup({
label: "labels.startTime",
items: [
{ label: "options.startTime.any", value: "ANY" },
{ label: "options.startTime.eu", value: "EU" },
{ label: "options.startTime.na", value: "NA" },
{ label: "options.startTime.au", value: "AU" },
],
}),
tagsIncluded: checkboxGroup({
label: "labels.tagsIncluded",
items: tagItems,
}),
tagsExcluded: checkboxGroup({
label: "labels.tagsExcluded",
items: tagItems,
}),
isSendou: toggle({ label: "labels.onlySendouEvents" }),
isRanked: toggle({ label: "labels.onlyRankedEvents" }),
minTeamCount: numberFieldOptional({
label: "labels.minTeamCount",
}),
orgsIncluded: array({
label: "labels.orgsIncluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
orgsExcluded: array({
label: "labels.orgsExcluded",
field: textFieldOptional({ maxLength: 100 }),
max: 10,
}),
authorIdsExcluded: array({
label: "labels.authorIdsExcluded",
field: userSearchOptional({}),
max: 10,
}),
})
.superRefine((filters, ctx) => {
if (

View File

@@ -1,21 +1,16 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { Funnel } from "lucide-react";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { SubmitButton } from "~/components/SubmitButton";
import type { CalendarEventTag } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
import type { CalendarFilters } from "~/features/calendar/calendar-types";
import { TagsFormField } from "~/features/calendar/components/TagsFormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
type FormValues = z.infer<typeof calendarFiltersFormSchema>;
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
const { t } = useTranslation(["calendar"]);
@@ -47,18 +42,6 @@ export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
);
}
const TAGS_TO_OMIT: Array<CalendarEventTag> = [
"CARDS",
"SR",
"S1",
"S2",
"SZ",
"TW",
"ONES",
"DUOS",
"TRIOS",
] as const;
function FiltersForm({
filters,
closeDialog,
@@ -67,156 +50,58 @@ function FiltersForm({
closeDialog: () => void;
}) {
const user = useUser();
const { t } = useTranslation(["game-misc", "calendar"]);
const methods = useForm({
resolver: standardSchemaResolver(calendarFiltersFormSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const { t } = useTranslation(["calendar"]);
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: CalendarFilters) => {
const handleApply = (values: FormValues) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
prev.set("filters", JSON.stringify(values));
return prev;
});
closeDialog();
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values as CalendarFilters);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(values as Parameters<typeof fetcher.submit>[0], {
method: "post",
encType: "application/json",
}),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.modes")}
name={"modes" as const}
values={[
{ label: t("game-misc:MODE_LONG_TW"), value: "TW" },
{ label: t("game-misc:MODE_LONG_SZ"), value: "SZ" },
{ label: t("game-misc:MODE_LONG_TC"), value: "TC" },
{ label: t("game-misc:MODE_LONG_RM"), value: "RM" },
{ label: t("game-misc:MODE_LONG_CB"), value: "CB" },
{ label: t("game-misc:MODE_LONG_SR"), value: "SR" },
{ label: t("game-misc:MODE_LONG_TB"), value: "TB" },
]}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.exactModes")}
name={"modesExact" as const}
bottomText={t("calendar:filter.exactModesBottom")}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.games")}
name={"games" as const}
values={[
{ label: t("game-misc:GAME_S1"), value: "S1" },
{ label: t("game-misc:GAME_S2"), value: "S2" },
{ label: t("game-misc:GAME_S3"), value: "S3" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="checkbox"
label={t("calendar:filter.vs")}
name={"preferredVersus" as const}
values={[
{ label: "4v4", value: "4v4" },
{ label: "3v3", value: "3v3" },
{ label: "2v2", value: "2v2" },
{ label: "1v1", value: "1v1" },
]}
/>
<InputGroupFormField<CalendarFilters>
type="radio"
label={t("calendar:filter.startTime")}
name={"preferredStartTime" as const}
values={[
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
{ label: t("calendar:filter.startTime.na"), value: "NA" },
{ label: t("calendar:filter.startTime.au"), value: "AU" },
]}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsIncluded")}
name={"tagsIncluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<TagsFormField<CalendarFilters>
label={t("calendar:filter.tagsExcluded")}
name={"tagsExcluded" as const}
tagsToOmit={TAGS_TO_OMIT}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isSendou")}
name={"isSendou" as const}
/>
<ToggleFormField<CalendarFilters>
label={t("calendar:filter.isRanked")}
name={"isRanked" as const}
/>
<InputFormField<CalendarFilters>
label={t("calendar:filter.minTeamCount")}
type="number"
name={"minTeamCount" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsIncluded")}
name={"orgsIncluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.orgsExcluded")}
name={"orgsExcluded" as const}
/>
<TextArrayFormField<CalendarFilters>
label={t("calendar:filter.authorIdsExcluded")}
name={"authorIdsExcluded" as const}
bottomText={t("calendar:filter.authorIdsExcludedBottom")}
/>
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("calendar:filter.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("calendar:filter.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
<SendouForm
schema={calendarFiltersFormSchema}
defaultValues={filters as unknown as FormValues}
onApply={handleApply}
submitButtonText={t("calendar:filter.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="modes" />
<FormField name="modesExact" />
<FormField name="games" />
<FormField name="preferredVersus" />
<FormField name="preferredStartTime" />
<FormField name="tagsIncluded" />
<FormField name="tagsExcluded" />
<FormField name="isSendou" />
<FormField name="isRanked" />
<FormField name="minTeamCount" />
<FormField name="orgsIncluded" />
<FormField name="orgsExcluded" />
<FormField name="authorIdsExcluded" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["calendar"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
return (
<SendouButton
variant="outlined"
onPress={() => submitToServer(values as CalendarFilters)}
isDisabled={fetcherState !== "idle"}
>
{t("calendar:filter.applyAndDefault")}
</SendouButton>
);
}

View File

@@ -1,101 +0,0 @@
import * as React from "react";
import { Tag, TagGroup, TagList } from "react-aria-components";
import {
Controller,
type FieldPath,
type FieldValues,
get,
useFormContext,
} from "react-hook-form";
import { useTranslation } from "react-i18next";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import type { CalendarEventTag } from "~/db/tables";
import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants";
import { tags as allTags } from "../calendar-constants";
import styles from "./TagsFormField.module.css";
export function TagsFormField<T extends FieldValues>({
label,
name,
bottomText,
tagsToOmit,
}: {
label: string;
name: FieldPath<T>;
bottomText?: string;
tagsToOmit?: Array<CalendarEventTag>;
}) {
const methods = useFormContext();
const id = React.useId();
const error = get(methods.formState.errors, name);
return (
<div className="w-full">
<Label htmlFor={id}>{label}</Label>
<Controller
control={methods.control}
name={name}
render={({ field: { onChange, value, ref } }) => (
<SelectableTags
selectedTags={value}
onSelectionChange={onChange}
tagsToOmit={tagsToOmit}
ref={ref}
/>
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
{bottomText && !error ? (
<FormMessage type="info">{bottomText}</FormMessage>
) : null}
</div>
);
}
const SelectableTags = React.forwardRef<
HTMLDivElement,
{
selectedTags: Array<CalendarEventTag>;
tagsToOmit?: Array<CalendarEventTag>;
onSelectionChange: (selectedTags: Array<CalendarEventTag>) => void;
}
>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
const { t } = useTranslation();
const availableTags = tagsToOmit
? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
: CALENDAR_EVENT.TAGS;
return (
<TagGroup
className={styles.tagGroup}
selectionMode="multiple"
selectedKeys={selectedTags}
onSelectionChange={(newSelection) =>
onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
}
aria-label="Select tags"
ref={ref}
>
<TagList className={styles.tagList}>
{availableTags.map((tag) => {
return (
<Tag
key={tag}
id={tag}
className={styles.tag}
style={{ "--tag-color": allTags[tag].color }}
>
{t(`tag.name.${tag}`)}
</Tag>
);
})}
</TagList>
</TagGroup>
);
});

View File

@@ -31,14 +31,12 @@ const createImage = async ({
const createTeam = async (ownerUserId: number) => {
teamCounter++;
const customUrl = `team-${teamCounter}`;
await TeamRepository.create({
const createdTeam = await TeamRepository.create({
name: `Team ${teamCounter}`,
customUrl,
ownerUserId,
isMainTeam: true,
});
const team = await TeamRepository.findByCustomUrl(customUrl);
const team = await TeamRepository.findByCustomUrl(createdTeam.customUrl);
if (!team) throw new Error("Team not found after creation");
return team;
};

View File

@@ -1,5 +1,4 @@
import type { FileUpload } from "@remix-run/form-data-parser";
import { parseFormData } from "@remix-run/form-data-parser";
import type { ActionFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { z } from "zod";
@@ -14,6 +13,7 @@ import {
badRequestIfFalsy,
errorToastIfFalsy,
parseSearchParams,
safeParseMultipartFormData,
} from "~/utils/remix.server";
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
import * as ImageRepository from "../ImageRepository.server";
@@ -57,7 +57,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return null;
};
const formData = await parseFormData(request, uploadHandler);
const formData = await safeParseMultipartFormData(request, uploadHandler);
const imgSrc = formData.get("img") as string | null;
invariant(imgSrc);

View File

@@ -12,16 +12,15 @@ const stm = sql.prepare(/* sql */ `
"ReportedWeapon"."weaponSplId",
count(*) as "count"
from "ReportedWeapon"
left join "GroupMatchMap" on "ReportedWeapon"."groupMatchMapId" = "GroupMatchMap"."id"
left join "GroupMatch" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
inner join "GroupMatchMap" on "ReportedWeapon"."groupMatchMapId" = "GroupMatchMap"."id"
inner join "GroupMatch" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
where "GroupMatch"."createdAt" between @starts and @ends
group by "ReportedWeapon"."userId", "ReportedWeapon"."weaponSplId"
order by "count" desc
)
select
"q1"."userId",
"q1"."weaponSplId",
"q1"."count"
max("q1"."count") as "count"
from "q1"
group by "q1"."userId"
`);

View File

@@ -0,0 +1,14 @@
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
export function replaceAll(
streams: Omit<TablesInsertable["LiveStream"], "id">[],
) {
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("LiveStream").execute();
if (streams.length > 0) {
await trx.insertInto("LiveStream").values(streams).execute();
}
});
}

View File

@@ -3,6 +3,7 @@ import {
DndContext,
DragOverlay,
PointerSensor,
TouchSensor,
useDraggable,
useSensor,
useSensors,
@@ -64,7 +65,15 @@ export default function Planner() {
previewPath: string;
} | null>(null);
const sensors = useSensors(useSensor(PointerSensor));
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
);
const handleMount = React.useCallback(
(mountedEditor: Editor) => {

View File

@@ -19,8 +19,10 @@ export const list =
!process.env.NODE_ENV ||
IS_E2E_TEST_RUN ||
// this gets checked when the project is running
// import.meta.env is undefined when Playwright bundles test code
(process.env.NODE_ENV === "development" &&
import.meta.env.VITE_PROD_MODE !== "true")
(typeof import.meta.env === "undefined" ||
import.meta.env.VITE_PROD_MODE !== "true"))
? ([
{
nth: 0,

View File

@@ -5,35 +5,42 @@ import type { Tables } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
actionError,
errorToast,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { scrimsPage } from "~/utils/urls";
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
import * as TeamRepository from "../../team/TeamRepository.server";
import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { SCRIM } from "../scrims-constants";
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import {
type fromSchema,
type newRequestSchema,
type RANGE_END_OPTIONS,
scrimsNewActionSchema,
scrimsNewFormSchema,
} from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
import { serializeLutiDiv } from "../scrims-utils";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: scrimsNewActionSchema,
schema: scrimsNewFormSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
if (data.from.mode === "PICKUP") {
if (data.from.users.includes(user.id)) {
return actionError<typeof newRequestSchema>({
@@ -55,11 +62,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
? resolveRangeEndToDate(data.at, data.rangeEnd)
: null;
const resolvedDivs = data.divs ? resolveDivs(data.divs) : null;
await ScrimPostRepository.insert({
at: dateToDatabaseTimestamp(data.at),
rangeEnd: rangeEndDate ? dateToDatabaseTimestamp(rangeEndDate) : null,
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
maxDiv: resolvedDivs?.[0] ? serializeLutiDiv(resolvedDivs[0]) : null,
minDiv: resolvedDivs?.[1] ? serializeLutiDiv(resolvedDivs[1]) : null,
text: data.postText,
managedByAnyone: data.managedByAnyone,
maps:
@@ -214,3 +223,18 @@ function resolveRangeEndToDate(
}
}
}
function resolveDivs(
divs: [LutiDiv | null, LutiDiv | null],
): [LutiDiv | null, LutiDiv | null] {
const [max, min] = divs;
if (!max || !min) return divs;
const maxIndex = LUTI_DIVS.indexOf(max);
const minIndex = LUTI_DIVS.indexOf(min);
if (minIndex < maxIndex) {
return [min, max];
}
return divs;
}

View File

@@ -1,104 +0,0 @@
import type * as React from "react";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Label } from "~/components/Label";
import { FormMessage } from "../../../components/FormMessage";
import { LUTI_DIVS } from "../scrims-constants";
import type { LutiDiv } from "../scrims-types";
export function LutiDivsFormField() {
const methods = useFormContext();
const error = methods.formState.errors.divs;
return (
<div>
<Controller
control={methods.control}
name="divs"
render={({ field: { onChange, onBlur, value } }) => (
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
)}
/>
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
);
}
type LutiDivEdit = {
max: LutiDiv | null;
min: LutiDiv | null;
};
function LutiDivsSelector({
value,
onChange,
onBlur,
}: {
value: LutiDivEdit | null;
onChange: (value: LutiDivEdit | null) => void;
onBlur: () => void;
}) {
const { t } = useTranslation(["scrims"]);
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.max
? { min: newValue, max: value?.max ?? null }
: null,
);
};
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
onChange(
newValue || value?.min
? { max: newValue, min: value?.min ?? null }
: null,
);
};
return (
<div className="stack horizontal sm">
<div>
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
<select
id="max-div"
value={value?.max ?? ""}
onChange={onChangeMax}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
<select
id="min-div"
value={value?.min ?? ""}
onChange={onChangeMin}
onBlur={onBlur}
>
<option value=""></option>
{LUTI_DIVS.map((div) => (
<option key={div} value={div}>
{div}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -1,17 +1,17 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { Funnel } from "lucide-react";
import * as React from "react";
import { FormProvider, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useFetcher, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import type { z } from "zod";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { InputFormField } from "~/components/form/InputFormField";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { ScrimFilters } from "~/features/scrims/scrims-types";
import { scrimsFiltersSchema } from "../scrims-schemas";
import { LutiDivsFormField } from "./LutiDivsFormField";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { scrimsFiltersFormSchema } from "../scrims-schemas";
import type { LutiDiv } from "../scrims-types";
type FormValues = z.infer<typeof scrimsFiltersFormSchema>;
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
const { t } = useTranslation(["scrims"]);
@@ -44,6 +44,26 @@ export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
);
}
function filtersToFormValues(filters: ScrimFilters): FormValues {
return {
weekdayTimes: filters.weekdayTimes,
weekendTimes: filters.weekendTimes,
divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null],
};
}
function formValuesToFilters(values: FormValues): ScrimFilters {
const [max, min] = values.divs ?? [null, null];
return {
weekdayTimes: values.weekdayTimes,
weekendTimes: values.weekendTimes,
divs:
max || min
? { max: max as LutiDiv | null, min: min as LutiDiv | null }
: null,
};
}
function FiltersForm({
filters,
closeDialog,
@@ -53,92 +73,56 @@ function FiltersForm({
}) {
const user = useUser();
const { t } = useTranslation(["scrims"]);
const methods = useForm({
resolver: standardSchemaResolver(scrimsFiltersSchema),
defaultValues: filters,
});
const fetcher = useFetcher<any>();
const [, setSearchParams] = useSearchParams();
const filtersToSearchParams = (newFilters: ScrimFilters) => {
const defaultValues = filtersToFormValues(filters);
const handleApply = (values: FormValues) => {
setSearchParams((prev) => {
prev.set("filters", JSON.stringify(newFilters));
prev.set("filters", JSON.stringify(formValuesToFilters(values)));
return prev;
});
closeDialog();
};
return (
<SendouForm
schema={scrimsFiltersFormSchema}
defaultValues={defaultValues}
onApply={handleApply}
submitButtonText={t("scrims:filters.apply")}
className="stack md-plus items-start"
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
>
{({ FormField }) => (
<>
<FormField name="weekdayTimes" />
<FormField name="weekendTimes" />
<FormField name="divs" />
</>
)}
</SendouForm>
);
}
function ApplyAndPersistButton() {
const { t } = useTranslation(["scrims"]);
const { values, submitToServer, fetcherState } = useFormFieldContext();
const handlePress = () => {
submitToServer({
_action: "PERSIST_SCRIM_FILTERS",
filters: formValuesToFilters(values as FormValues),
});
};
const onApply = React.useCallback(
methods.handleSubmit((values) => {
filtersToSearchParams(values as ScrimFilters);
closeDialog();
}),
[],
);
const onApplyAndPersist = React.useCallback(
methods.handleSubmit((values) =>
fetcher.submit(
// @ts-expect-error TODO: fix
{
_action: "PERSIST_SCRIM_FILTERS",
filters: values as Parameters<typeof fetcher.submit>[0],
},
{
method: "post",
encType: "application/json",
},
),
),
[],
);
return (
<FormProvider {...methods}>
<fetcher.Form
className="stack md-plus items-start"
onSubmit={onApplyAndPersist}
>
<input type="hidden" name="_action" value="PERSIST_SCRIM_FILTERS" />
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayStart")}
name={"weekdayTimes.start" as const}
type="time"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekdayEnd")}
name={"weekdayTimes.end" as const}
type="time"
/>
</div>
<div className="stack sm horizontal">
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendStart")}
name={"weekendTimes.start" as const}
type="time"
/>
<InputFormField<ScrimFilters>
label={t("scrims:filters.weekendEnd")}
name={"weekendTimes.end" as const}
type="time"
/>
</div>
<LutiDivsFormField />
<div className="stack horizontal md justify-center mt-6 w-full">
<SendouButton onPress={() => onApply()}>
{t("scrims:filters.apply")}
</SendouButton>
{user ? (
<SubmitButton variant="outlined" state={fetcher.state}>
{t("scrims:filters.applyAndDefault")}
</SubmitButton>
) : null}
</div>
</fetcher.Form>
</FormProvider>
<SendouButton
variant="outlined"
onPress={handlePress}
isDisabled={fetcherState !== "idle"}
>
{t("scrims:filters.applyAndDefault")}
</SendouButton>
);
}

View File

@@ -2,16 +2,14 @@ import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouDialog } from "~/components/elements/Dialog";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import type { CustomFieldRenderProps } from "~/form";
import { SendouForm } from "~/form/SendouForm";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { nullFilledArray } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import type { loader as scrimsLoader } from "../loaders/scrims.server";
import type { NewRequestFormFields } from "../routes/scrims";
import { SCRIM } from "../scrims-constants";
import { newRequestSchema } from "../scrims-schemas";
import { scrimRequestFormSchema } from "../scrims-schemas";
import type { ScrimPost } from "../scrims-types";
import { generateTimeOptions } from "../scrims-utils";
import { WithFormField } from "./WithFormField";
@@ -32,7 +30,7 @@ export function ScrimRequestModal({
databaseTimestampToDate(post.at),
databaseTimestampToDate(post.rangeEnd),
).map((timestamp) => ({
value: timestamp,
value: String(timestamp),
label: formatTime(new Date(timestamp)),
}))
: [];
@@ -40,9 +38,8 @@ export function ScrimRequestModal({
return (
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
<SendouForm
schema={newRequestSchema}
schema={scrimRequestFormSchema}
defaultValues={{
_action: "NEW_REQUEST",
scrimPostId: post.id,
from:
data.teams.length > 0
@@ -54,32 +51,31 @@ export function ScrimRequestModal({
) as unknown as number[],
},
message: "",
at: post.rangeEnd ? (timeOptions[0]?.value as unknown as Date) : null,
at: post.rangeEnd && timeOptions[0] ? timeOptions[0].value : null,
}}
>
<div className="font-semi-bold text-lighter italic">
{new Intl.ListFormat(i18n.language).format(
post.users.map((u) => u.username),
)}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<WithFormField usersTeams={data.teams} />
{post.rangeEnd ? (
<SelectFormField<NewRequestFormFields>
name="at"
label={t("scrims:requestModal.at.label")}
bottomText={t("scrims:requestModal.at.explanation")}
values={timeOptions}
/>
) : null}
<TextAreaFormField<NewRequestFormFields>
name="message"
label={t("scrims:requestModal.message.label")}
maxLength={SCRIM.REQUEST_MESSAGE_MAX_LENGTH}
/>
{({ FormField }) => (
<>
<div className="font-semi-bold text-lighter italic">
{new Intl.ListFormat(i18n.language).format(
post.users.map((u) => u.username),
)}
</div>
{post.text ? (
<div className="text-sm text-lighter italic">{post.text}</div>
) : null}
<Divider />
<FormField name="from">
{(props: CustomFieldRenderProps) => (
<WithFormField usersTeams={data.teams} {...props} />
)}
</FormField>
{post.rangeEnd ? (
<FormField name="at" options={timeOptions} />
) : null}
<FormField name="message" />
</>
)}
</SendouForm>
</SendouDialog>
);

View File

@@ -1,107 +1,117 @@
import { Controller, useFormContext } from "react-hook-form";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod";
import { UserSearch } from "~/components/elements/UserSearch";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { useUser } from "~/features/auth/core/user";
import { SCRIM } from "~/features/scrims/scrims-constants";
import {
FormFieldWrapper,
useTranslatedTexts,
} from "~/form/fields/FormFieldWrapper";
import { errorMessageId } from "~/form/utils";
import { nullFilledArray } from "~/utils/arrays";
import type { CommonUser } from "~/utils/kysely.server";
import type { NewRequestFormFields } from "../routes/scrims";
import type { fromSchema } from "../scrims-schemas";
interface FromFormFieldProps {
type FromValue = z.infer<typeof fromSchema>;
interface WithFormFieldProps {
usersTeams: Array<{
id: number;
name: string;
members: Array<CommonUser>;
}>;
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}
export function WithFormField({ usersTeams }: FromFormFieldProps) {
export function WithFormField({
usersTeams,
name,
value,
onChange,
error,
}: WithFormFieldProps) {
const { t } = useTranslation(["scrims"]);
const user = useUser();
const methods = useFormContext<NewRequestFormFields>();
const id = React.useId();
const { translatedError } = useTranslatedTexts({ error });
const fromValue = value as FromValue | null;
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.value === "PICKUP") {
onChange({
mode: "PICKUP",
users: nullFilledArray(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER),
});
return;
}
onChange({ teamId: Number(e.target.value), mode: "TEAM" });
};
const handleUserChange = (
selectedUser: { id: number } | null,
index: number,
) => {
if (!fromValue || fromValue.mode !== "PICKUP") return;
onChange({
mode: "PICKUP",
users: fromValue.users.map((u, j) =>
j === index ? selectedUser?.id : u,
),
});
};
const selectValue = fromValue?.mode === "TEAM" ? fromValue.teamId : "PICKUP";
return (
<div>
<Label htmlFor="with">{t("scrims:forms.with.title")}</Label>
<Controller
control={methods.control}
name="from"
render={({ field: { onChange, onBlur, value }, fieldState }) => {
const setTeam = (teamId: number) => {
onChange({ teamId, mode: "TEAM" });
};
const error =
(fieldState.error as any)?.users ?? fieldState.error?.root;
return (
<div>
<select
id="with"
className="w-max"
value={value.mode === "TEAM" ? value.teamId : "PICKUP"}
onChange={(e) => {
if (e.target.value === "PICKUP") {
onChange({
mode: "PICKUP",
users: nullFilledArray(
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
),
});
return;
}
setTeam(Number(e.target.value));
}}
onBlur={onBlur}
>
{usersTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
</select>
{value.mode === "PICKUP" ? (
<div className="stack md mt-4">
<UserSearch
initialUserId={user!.id}
isDisabled
label={t("scrims:forms.with.user", { nth: 1 })}
/>
{value.users.map((userId, i) => (
<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">
{error.message as string}
</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.with.explanation")}
</FormMessage>
)}
</div>
) : null}
</div>
);
}}
/>
</div>
<FormFieldWrapper
id={id}
name={name}
label={t("scrims:forms.with.title")}
error={fromValue?.mode === "TEAM" ? error : undefined}
>
<select id={id} value={selectValue} onChange={handleSelectChange}>
{usersTeams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
</select>
{fromValue?.mode === "PICKUP" ? (
<div className="stack md mt-4">
<UserSearch
initialUserId={user!.id}
isDisabled
label={t("scrims:forms.with.user", { nth: 1 })}
/>
{fromValue.users.map((userId, i) => (
<UserSearch
key={i}
initialUserId={userId}
onChange={(selectedUser) => handleUserChange(selectedUser, i)}
isRequired={i < 3}
label={t("scrims:forms.with.user", { nth: i + 2 })}
/>
))}
{translatedError ? (
<FormMessage type="error" id={errorMessageId(name)}>
{translatedError}
</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.with.explanation")}
</FormMessage>
)}
</div>
) : null}
</FormFieldWrapper>
);
}

View File

@@ -2,19 +2,16 @@ import clsx from "clsx";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link, useLoaderData } from "react-router";
import type { z } from "zod";
import { Alert } from "~/components/Alert";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { Image } from "~/components/Image";
import TimePopover from "~/components/TimePopover";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { SCRIM } from "~/features/scrims/scrims-constants";
import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { SendouForm } from "~/form/SendouForm";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { useHasPermission } from "~/modules/permissions/hooks";
import type { SerializeFrom } from "~/utils/remix";
@@ -118,23 +115,13 @@ export default function ScrimPage() {
);
}
type FormFields = z.infer<typeof cancelScrimSchema>;
function CancelScrimForm() {
const { t } = useTranslation(["scrims"]);
return (
<SendouForm
schema={cancelScrimSchema}
defaultValues={{ reason: "" }}
submitButtonTestId="cancel-scrim-submit"
>
<TextAreaFormField<FormFields>
name="reason"
label={t("cancelModal.scrim.reasonLabel")}
maxLength={SCRIM.CANCEL_REASON_MAX_LENGTH}
bottomText={t("scrims:cancelModal.scrim.reasonExplanation")}
/>
{({ FormField }) => <FormField name="reason" />}
</SendouForm>
);
}

View File

@@ -0,0 +1,4 @@
.datePickerFullWidth {
--input-width: 100%;
width: 100%;
}

View File

@@ -9,9 +9,9 @@ import {
} from "~/utils/Test";
import { action } from "../actions/scrims.new.server";
import { loader } from "../loaders/scrims.server";
import type { scrimsNewActionSchema } from "../scrims-schemas";
import type { scrimsNewFormSchema } from "../scrims-schemas";
const newScrimAction = wrappedAction<typeof scrimsNewActionSchema>({
const newScrimAction = wrappedAction<typeof scrimsNewFormSchema>({
action,
isJsonSubmission: true,
});
@@ -24,7 +24,7 @@ const defaultNewScrimPostArgs: Parameters<typeof newScrimAction>[0] = {
at: new Date(),
rangeEnd: null,
baseVisibility: "PUBLIC",
divs: { min: null, max: null },
divs: [null, null],
from: {
mode: "PICKUP",
users: [1, 3, 4],

View File

@@ -1,36 +1,33 @@
import type { CalendarDateTime } from "@internationalized/date";
import * as React from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import type { z } from "zod";
import { SendouDatePicker } from "~/components/elements/DatePicker";
import { TournamentSearch } from "~/components/elements/TournamentSearch";
import { DateFormField } from "~/components/form/DateFormField";
import { SelectFormField } from "~/components/form/SelectFormField";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { ToggleFormField } from "~/components/form/ToggleFormField";
import { Label } from "~/components/Label";
import type { CustomFieldRenderProps } from "~/form";
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
import { errorMessageId } from "~/form/utils";
import { nullFilledArray } from "~/utils/arrays";
import { dateToDateValue } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FormMessage } from "../../../components/FormMessage";
import { Main } from "../../../components/Main";
import { action } from "../actions/scrims.new.server";
import { LutiDivsFormField } from "../components/LutiDivsFormField";
import { WithFormField } from "../components/WithFormField";
import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server";
import { SCRIM } from "../scrims-constants";
import {
MAX_SCRIM_POST_TEXT_LENGTH,
RANGE_END_OPTIONS,
scrimsNewActionSchema,
} from "../scrims-schemas";
import { scrimsNewFormSchema } from "../scrims-schemas";
import styles from "./scrims.new.module.css";
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: "scrims",
};
type FormFields = z.infer<typeof scrimsNewActionSchema>;
type FormFields = z.infer<typeof scrimsNewFormSchema>;
const DEFAULT_NOT_FOUND_VISIBILITY = {
at: null,
@@ -44,13 +41,12 @@ export default function NewScrimPage() {
return (
<Main>
<SendouForm
schema={scrimsNewActionSchema}
heading={t("scrims:forms.title")}
schema={scrimsNewFormSchema}
title={t("scrims:forms.title")}
defaultValues={{
postText: "",
at: new Date(),
rangeEnd: null,
divs: null,
baseVisibility: "PUBLIC",
notFoundVisibility: DEFAULT_NOT_FOUND_VISIBILITY,
from:
@@ -67,64 +63,39 @@ export default function NewScrimPage() {
mapsTournamentId: null,
}}
>
<WithFormField usersTeams={data.teams} />
{({ FormField }) => (
<>
<FormField name="from">
{(props: CustomFieldRenderProps) => (
<WithFormField usersTeams={data.teams} {...props} />
)}
</FormField>
<DateFormField<FormFields>
label={t("scrims:forms.when.title")}
name="at"
bottomText={t("scrims:forms.when.explanation")}
granularity="minute"
/>
<SelectFormField<FormFields>
label={t("scrims:forms.rangeEnd.title")}
name="rangeEnd"
bottomText={t("scrims:forms.rangeEnd.explanation")}
values={[
{
value: "",
label: t("scrims:forms.rangeEnd.notFlexible"),
},
...RANGE_END_OPTIONS.map((option) => ({
value: option,
label: t(`scrims:forms.rangeEnd.${option}`),
})),
]}
/>
<FormField name="at" />
<FormField name="rangeEnd" />
<BaseVisibilityFormField associations={data.associations} />
<FormField name="baseVisibility">
{(props: CustomFieldRenderProps) => (
<BaseVisibilityFormField
associations={data.associations}
{...props}
/>
)}
</FormField>
<NotFoundVisibilityFormField associations={data.associations} />
<NotFoundVisibilityFormField associations={data.associations} />
<LutiDivsFormField />
<FormField name="divs" />
<SelectFormField<FormFields>
label={t("scrims:forms.maps.title")}
name="maps"
values={[
{
value: "NO_PREFERENCE",
label: t("scrims:forms.maps.noPreference"),
},
{ value: "SZ", label: t("scrims:forms.maps.szOnly") },
{ value: "RANKED", label: t("scrims:forms.maps.rankedOnly") },
{ value: "ALL", label: t("scrims:forms.maps.allModes") },
{ value: "TOURNAMENT", label: t("scrims:forms.maps.tournament") },
]}
/>
<FormField name="maps" />
<TournamentSearchFormField />
<TournamentSearchFormField />
<TextAreaFormField<FormFields>
label={t("scrims:forms.text.title")}
name="postText"
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
/>
<FormField name="postText" />
<ToggleFormField<FormFields>
label={t("scrims:forms.managedByAnyone.title")}
name="managedByAnyone"
bottomText={t("scrims:forms.managedByAnyone.explanation")}
/>
<FormField name="managedByAnyone" />
</>
)}
</SendouForm>
</Main>
);
@@ -132,20 +103,30 @@ export default function NewScrimPage() {
function BaseVisibilityFormField({
associations,
name,
value,
onChange,
error,
}: {
associations: ScrimsNewLoaderData["associations"];
name: string;
value: unknown;
onChange: (value: unknown) => void;
error: string | undefined;
}) {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const error = methods.formState.errors.baseVisibility;
const id = React.useId();
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
return (
<div>
<Label htmlFor="visibility">{t("scrims:forms.visibility.title")}</Label>
<FormFieldWrapper
id={id}
name={name}
label={t("scrims:forms.visibility.title")}
error={error}
>
{noAssociations ? (
<FormMessage type="info">
{t("scrims:forms.visibility.noneAvailable")}
@@ -153,15 +134,12 @@ function BaseVisibilityFormField({
) : (
<AssociationSelect
associations={associations}
id="visibility"
{...methods.register("baseVisibility")}
id={id}
value={String(value)}
onChange={(e) => onChange(e.target.value)}
/>
)}
{error && (
<FormMessage type="error">{error.message as string}</FormMessage>
)}
</div>
</FormFieldWrapper>
);
}
@@ -170,68 +148,112 @@ function NotFoundVisibilityFormField({
}: {
associations: ScrimsNewLoaderData["associations"];
}) {
const { t } = useTranslation(["scrims"]);
const baseVisibility = useWatch<FormFields>({
name: "baseVisibility",
});
const date = useWatch<FormFields>({ name: "notFoundVisibility.at" }) ?? "";
const methods = useFormContext<FormFields>();
const { t } = useTranslation(["scrims", "forms"]);
const { values, setValue, clientErrors, serverErrors } =
useFormFieldContext();
const baseVisibility = values.baseVisibility as string;
const notFoundVisibility =
values.notFoundVisibility as FormFields["notFoundVisibility"];
React.useEffect(() => {
const prevBaseVisibility = React.useRef(baseVisibility);
if (prevBaseVisibility.current !== baseVisibility) {
prevBaseVisibility.current = baseVisibility;
if (baseVisibility === "PUBLIC") {
methods.setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
}
}, [baseVisibility, methods.setValue]);
}
const error = methods.formState.errors.notFoundVisibility;
const error =
serverErrors.notFoundVisibility ?? clientErrors.notFoundVisibility;
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
if (noAssociations || baseVisibility === "PUBLIC") return null;
const handleDateChange = (val: CalendarDateTime | null) => {
if (val) {
const date = new Date(
val.year,
val.month - 1,
val.day,
val.hour,
val.minute,
);
setValue("notFoundVisibility", {
...notFoundVisibility,
at: date,
});
} else {
setValue("notFoundVisibility", {
...notFoundVisibility,
at: null,
});
}
};
const handleAssociationChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setValue("notFoundVisibility", {
...notFoundVisibility,
forAssociation: e.target.value,
});
};
const dateValue = notFoundVisibility.at
? dateToDateValue(new Date(notFoundVisibility.at))
: null;
return (
<div>
<div className="stack horizontal sm">
<DateFormField<FormFields>
label={t("scrims:forms.notFoundVisibility.title")}
name="notFoundVisibility.at"
granularity="minute"
/>
{date ? (
<div>
<div className={styles.datePickerFullWidth}>
<SendouDatePicker
label={t("scrims:forms.notFoundVisibility.title")}
granularity="minute"
errorText={error ? t(`forms:${error}` as never) : undefined}
errorId={errorMessageId("notFoundVisibility")}
value={dateValue}
onChange={handleDateChange}
bottomText={
notFoundVisibility.at
? undefined
: t("scrims:forms.notFoundVisibility.explanation")
}
/>
</div>
{notFoundVisibility.at ? (
<div className="w-full">
<Label htmlFor="not-found-visibility">
{t("scrims:forms.visibility.title")}
</Label>
<AssociationSelect
associations={associations}
id="not-found-visibility"
{...methods.register("notFoundVisibility.forAssociation")}
value={String(notFoundVisibility.forAssociation)}
onChange={handleAssociationChange}
/>
</div>
) : null}
</div>
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
) : (
<FormMessage type="info">
{t("scrims:forms.notFoundVisibility.explanation")}
</FormMessage>
)}
</div>
);
}
const AssociationSelect = React.forwardRef<
HTMLSelectElement,
{
associations: ScrimsNewLoaderData["associations"];
} & React.SelectHTMLAttributes<HTMLSelectElement>
>(({ associations, ...rest }, ref) => {
function AssociationSelect({
associations,
id,
value,
onChange,
}: {
associations: ScrimsNewLoaderData["associations"];
id: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}) {
const { t } = useTranslation(["scrims"]);
return (
<select ref={ref} {...rest}>
<select id={id} className="w-full" value={value} onChange={onChange}>
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
{associations.virtual.map((association) => (
<option key={association} value={association}>
@@ -245,40 +267,42 @@ const AssociationSelect = React.forwardRef<
))}
</select>
);
});
}
function TournamentSearchFormField() {
const { t } = useTranslation(["scrims"]);
const methods = useFormContext<FormFields>();
const maps = useWatch<FormFields>({ name: "maps" });
const { values, setValue, clientErrors, serverErrors } =
useFormFieldContext();
const maps = values.maps as string;
const mapsTournamentId = values.mapsTournamentId as number | null;
const error = methods.formState.errors.mapsTournamentId;
const error = serverErrors.mapsTournamentId ?? clientErrors.mapsTournamentId;
const prevMaps = React.useRef(maps);
React.useEffect(() => {
if (maps !== "TOURNAMENT") {
methods.setValue("mapsTournamentId", null);
if (prevMaps.current !== maps) {
prevMaps.current = maps;
if (maps !== "TOURNAMENT") {
setValue("mapsTournamentId", null);
}
}
}, [maps, methods]);
}, [maps, setValue]);
if (maps !== "TOURNAMENT") return null;
return (
<div>
<Controller
control={methods.control}
name="mapsTournamentId"
render={({ field: { onChange, value } }) => (
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={value ?? undefined}
onChange={(tournament) => onChange(tournament?.id)}
/>
)}
<FormFieldWrapper
id="mapsTournamentId"
name="mapsTournamentId"
error={error}
>
<TournamentSearch
label={t("scrims:forms.mapsTournament.title")}
initialTournamentId={mapsTournamentId ?? undefined}
onChange={(tournament) =>
setValue("mapsTournamentId", tournament?.id ?? null)
}
/>
{error ? (
<FormMessage type="error">{error.message as string}</FormMessage>
) : null}
</div>
</FormFieldWrapper>
);
}

View File

@@ -1,5 +1,19 @@
import { add, sub } from "date-fns";
import { z } from "zod";
import {
customField,
datetimeRequired,
dualSelectOptional,
idConstant,
select,
selectDynamicOptional,
selectOptional,
stringConstant,
textAreaOptional,
textAreaRequired,
timeRangeOptional,
toggle,
} from "~/form/fields";
import {
_action,
date,
@@ -23,11 +37,11 @@ const fromUsers = z.preprocess(
z
.array(id)
.min(3, {
message: "Must have at least 3 users excluding yourself",
message: "forms:errors.minUsersExcludingYourself",
})
.max(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER)
.refine(noDuplicates, {
message: "Users must be unique",
message: "forms:errors.usersMustBeUnique",
}),
);
@@ -58,7 +72,11 @@ const cancelRequestSchema = z.object({
});
export const cancelScrimSchema = z.object({
reason: z.string().trim().min(1).max(SCRIM.CANCEL_REASON_MAX_LENGTH),
reason: textAreaRequired({
label: "labels.scrimCancelReason",
bottomText: "bottomTexts.scrimCancelReasonHelp",
maxLength: SCRIM.CANCEL_REASON_MAX_LENGTH,
}),
});
const timeRangeSchema = z.object({
@@ -81,7 +99,7 @@ export const divsSchema = z
return true;
},
{
message: "Both min and max div must be set or neither",
message: "forms:errors.divBothOrNeither",
},
)
.transform((divs) => {
@@ -97,12 +115,46 @@ export const divsSchema = z
return divs;
});
export const scrimsFiltersSchema = z.object({
const scrimsFiltersSchema = z.object({
weekdayTimes: timeRangeSchema.nullable().catch(null),
weekendTimes: timeRangeSchema.nullable().catch(null),
divs: divsSchema.nullable().catch(null),
});
const divsFormField = dualSelectOptional({
fields: [
{
label: "labels.scrimMaxDiv",
items: LUTI_DIVS.map((div) => ({ label: () => div, value: div })),
},
{
label: "labels.scrimMinDiv",
items: LUTI_DIVS.map((div) => ({ label: () => div, value: div })),
},
],
validate: {
func: ([max, min]) => {
if ((max && !min) || (!max && min)) return false;
return true;
},
message: "errors.divBothOrNeither",
},
});
export const scrimsFiltersFormSchema = z.object({
weekdayTimes: timeRangeOptional({
label: "labels.weekdayTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
weekendTimes: timeRangeOptional({
label: "labels.weekendTimes",
startLabel: "labels.start",
endLabel: "labels.end",
}),
divs: divsFormField,
});
export const scrimsFiltersSearchParamsObject = z.object({
filters: z
.preprocess(safeJSONParse, scrimsFiltersSchema)
@@ -122,7 +174,7 @@ export const scrimsActionSchema = z.union([
persistScrimFiltersSchema,
]);
export const MAX_SCRIM_POST_TEXT_LENGTH = 500;
const MAX_SCRIM_POST_TEXT_LENGTH = 500;
export const RANGE_END_OPTIONS = [
"+30min",
@@ -133,73 +185,98 @@ export const RANGE_END_OPTIONS = [
"+3hours",
] as const;
export const scrimsNewActionSchema = z
export const scrimRequestFormSchema = z.object({
_action: stringConstant("NEW_REQUEST"),
scrimPostId: idConstant(),
from: customField({ initialValue: null }, fromSchema),
message: textAreaOptional({
label: "labels.scrimRequestMessage",
maxLength: SCRIM.REQUEST_MESSAGE_MAX_LENGTH,
}),
at: selectDynamicOptional({
label: "labels.scrimRequestStartTime",
bottomText: "bottomTexts.scrimRequestStartTime",
}),
});
const rangeEndItems = [
{ label: "options.scrimFlexibility.notFlexible" as const, value: "" },
{ label: "options.scrimFlexibility.+30min" as const, value: "+30min" },
{ label: "options.scrimFlexibility.+1hour" as const, value: "+1hour" },
{ label: "options.scrimFlexibility.+1.5hours" as const, value: "+1.5hours" },
{ label: "options.scrimFlexibility.+2hours" as const, value: "+2hours" },
{ label: "options.scrimFlexibility.+2.5hours" as const, value: "+2.5hours" },
{ label: "options.scrimFlexibility.+3hours" as const, value: "+3hours" },
] as const;
const mapsItems = [
{ label: "options.scrimMaps.noPreference" as const, value: "NO_PREFERENCE" },
{ label: "options.scrimMaps.szOnly" as const, value: "SZ" },
{ label: "options.scrimMaps.rankedOnly" as const, value: "RANKED" },
{ label: "options.scrimMaps.allModes" as const, value: "ALL" },
{ label: "options.scrimMaps.tournament" as const, value: "TOURNAMENT" },
] as const;
export const scrimsNewFormSchema = z
.object({
at: z.preprocess(
date,
z
.date()
.refine(
(date) => {
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{
message: "Date can not be in the past",
},
)
.refine(
(date) => {
if (date > add(new Date(), { days: 15 })) return false;
return true;
},
{
message: "Date can not be more than 2 weeks in the future",
},
),
),
rangeEnd: z
.preprocess(
(val) => (val === "" ? null : val),
z.enum(RANGE_END_OPTIONS).nullable(),
)
.catch(null),
baseVisibility: associationIdentifierSchema,
notFoundVisibility: z.object({
at: z
.preprocess(date, z.date())
.nullish()
.refine(
(date) => {
if (!date) return true;
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{
message: "Date can not be in the past",
},
),
forAssociation: associationIdentifierSchema,
at: datetimeRequired({
label: "labels.start",
bottomText: "bottomTexts.scrimStart",
min: sub(new Date(), { days: 1 }),
max: add(new Date(), { days: 15 }),
minMessage: "errors.dateInPast",
maxMessage: "errors.dateTooFarInFuture",
}),
divs: divsSchema.nullable(),
from: fromSchema,
postText: z.preprocess(
falsyToNull,
z.string().max(MAX_SCRIM_POST_TEXT_LENGTH).nullable(),
rangeEnd: selectOptional({
label: "labels.scrimStartFlexibility",
bottomText: "bottomTexts.scrimStartFlexibility",
items: [...rangeEndItems],
}),
baseVisibility: customField(
{ initialValue: "PUBLIC" },
associationIdentifierSchema,
),
notFoundVisibility: customField(
{ initialValue: { at: null, forAssociation: "PUBLIC" } },
z.object({
at: z
.preprocess(date, z.date())
.nullish()
.refine(
(date) => {
if (!date) return true;
if (date < sub(new Date(), { days: 1 })) return false;
return true;
},
{ message: "errors.dateInPast" },
),
forAssociation: associationIdentifierSchema,
}),
),
divs: divsFormField,
from: customField({ initialValue: null }, fromSchema),
postText: textAreaOptional({
label: "labels.text",
maxLength: MAX_SCRIM_POST_TEXT_LENGTH,
}),
managedByAnyone: toggle({
label: "labels.scrimManagedByAnyone",
bottomText: "bottomTexts.scrimManagedByAnyone",
}),
maps: select({
label: "labels.scrimMaps",
items: [...mapsItems],
}),
mapsTournamentId: customField(
{ initialValue: null },
z.preprocess(falsyToNull, id.nullable()),
),
managedByAnyone: z.boolean(),
maps: z.enum(["NO_PREFERENCE", "SZ", "RANKED", "ALL", "TOURNAMENT"]),
mapsTournamentId: z.preprocess(falsyToNull, id.nullable()),
})
.superRefine((post, ctx) => {
if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament must be selected when maps is tournament",
message: "forms:errors.tournamentMustBeSelected",
code: z.ZodIssueCode.custom,
});
}
@@ -207,17 +284,18 @@ export const scrimsNewActionSchema = z
if (post.maps !== "TOURNAMENT" && post.mapsTournamentId) {
ctx.addIssue({
path: ["mapsTournamentId"],
message: "Tournament should only be selected when maps is tournament",
message: "forms:errors.tournamentOnlyWhenMapsIsTournament",
code: z.ZodIssueCode.custom,
});
}
if (
post.notFoundVisibility.at &&
post.notFoundVisibility.forAssociation === post.baseVisibility
) {
ctx.addIssue({
path: ["notFoundVisibility"],
message: "Not found visibility must be different from base visibility",
message: "forms:errors.visibilityMustBeDifferent",
code: z.ZodIssueCode.custom,
});
}
@@ -225,16 +303,15 @@ export const scrimsNewActionSchema = z
if (post.baseVisibility === "PUBLIC" && post.notFoundVisibility.at) {
ctx.addIssue({
path: ["notFoundVisibility"],
message:
"Not found visibility can not be set if base visibility is public",
message: "forms:errors.visibilityNotAllowedWhenPublic",
code: z.ZodIssueCode.custom,
});
}
if (post.notFoundVisibility.at && post.notFoundVisibility.at < post.at) {
if (post.notFoundVisibility.at && post.notFoundVisibility.at > post.at) {
ctx.addIssue({
path: ["notFoundVisibility", "at"],
message: "Date can not be before the scrim date",
path: ["notFoundVisibility"],
message: "forms:errors.dateAfterScrimDate",
code: z.ZodIssueCode.custom,
});
}
@@ -242,7 +319,7 @@ export const scrimsNewActionSchema = z
if (post.notFoundVisibility.at && post.at < new Date()) {
ctx.addIssue({
path: ["notFoundVisibility"],
message: "Can not be set if looking for scrim now",
message: "forms:errors.canNotSetIfLookingNow",
code: z.ZodIssueCode.custom,
});
}

View File

@@ -1,5 +1,7 @@
import { db } from "~/db/sql";
import type { QWeaponPool, Tables, UserMapModePreferences } from "~/db/tables";
import type { Tables, UserMapModePreferences } from "~/db/tables";
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
@@ -18,7 +20,9 @@ export async function settingsByUserId(userId: number) {
return {
...preferences,
languages: preferences.languages?.split(","),
languages: preferences.languages?.split(",") as
| UnifiedLanguageCode[]
| undefined,
};
}
@@ -74,13 +78,20 @@ export function updateVoiceChat(args: {
export function updateSendouQWeaponPool(args: {
userId: number;
weaponPool: QWeaponPool[];
weaponPool: WeaponPoolItem[];
}) {
return db
.updateTable("User")
.set({
qWeaponPool:
args.weaponPool.length > 0 ? JSON.stringify(args.weaponPool) : null,
args.weaponPool.length > 0
? JSON.stringify(
args.weaponPool.map((wpn) => ({
weaponSplId: wpn.id,
isFavorite: Number(wpn.isFavorite),
})),
)
: null,
})
.where("User.id", "=", args.userId)
.execute();

View File

@@ -34,13 +34,6 @@ export const action = async ({ request }: { request: Request }) => {
});
break;
}
case "UPDATE_NO_SCREEN": {
await QSettingsRepository.updateNoScreen({
userId: user.id,
noScreen: Number(data.noScreen),
});
break;
}
case "REMOVE_TRUST": {
await QSettingsRepository.deleteTrustedUser({
trustGiverUserId: user.id,

View File

@@ -1,19 +1,10 @@
import { z } from "zod";
import { languagesUnified } from "~/modules/i18n/config";
import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "./q-settings-constants";
import {
_action,
checkboxValueToBoolean,
id,
modeShort,
noDuplicates,
qWeapon,
safeJSONParse,
stageId,
} from "~/utils/zod";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
SENDOUQ_WEAPON_POOL_MAX_SIZE,
} from "./q-settings-constants";
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "./q-settings-schemas";
const preference = z.enum(["AVOID", "PREFER"]).optional();
export const settingsActionSchema = z.union([
@@ -41,30 +32,8 @@ export const settingsActionSchema = z.union([
),
),
}),
z.object({
_action: _action("UPDATE_VC"),
vc: z.enum(["YES", "NO", "LISTEN_ONLY"]),
languages: z.preprocess(
safeJSONParse,
z
.array(z.string())
.refine(noDuplicates)
.refine((val) =>
val.every((lang) => languagesUnified.some((l) => l.code === lang)),
),
),
}),
z.object({
_action: _action("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: z.preprocess(
safeJSONParse,
z.array(qWeapon).max(SENDOUQ_WEAPON_POOL_MAX_SIZE),
),
}),
z.object({
_action: _action("UPDATE_NO_SCREEN"),
noScreen: z.preprocess(checkboxValueToBoolean, z.boolean()),
}),
updateVoiceChatSchema,
updateWeaponPoolSchema,
z.object({
_action: _action("REMOVE_TRUST"),
userToRemoveTrustFromId: id,

View File

@@ -0,0 +1,38 @@
import { z } from "zod";
import {
checkboxGroup,
radioGroup,
stringConstant,
weaponPool,
} from "~/form/fields";
import { languagesUnified } from "~/modules/i18n/config";
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "./q-settings-constants";
export const updateWeaponPoolSchema = z.object({
_action: stringConstant("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: weaponPool({
label: "labels.weaponPool",
maxCount: SENDOUQ_WEAPON_POOL_MAX_SIZE,
}),
});
const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({
label: () => lang.name,
value: lang.code,
}));
export const updateVoiceChatSchema = z.object({
_action: stringConstant("UPDATE_VC"),
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
languages: checkboxGroup({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
});

View File

@@ -15,29 +15,28 @@ import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage, WeaponImage } from "~/components/Image";
import { ModeImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { WeaponSelect } from "~/components/WeaponSelect";
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
import type { Preference, UserMapModePreferences } from "~/db/tables";
import {
soundCodeToLocalStorageKey,
soundVolume,
} from "~/features/chat/chat-utils";
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { languagesUnified } from "~/modules/i18n/config";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
navIconUrl,
SENDOUQ_PAGE,
SENDOUQ_SETTINGS_PAGE,
SETTINGS_PAGE,
soundPath,
} from "~/utils/urls";
import { action } from "../actions/q.settings.server";
@@ -45,10 +44,11 @@ import { BANNED_MAPS } from "../banned-maps";
import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker";
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
import { loader } from "../loaders/q.settings.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "../q-settings-constants";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
SENDOUQ_WEAPON_POOL_MAX_SIZE,
} from "../q-settings-constants";
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "../q-settings-schemas";
export { loader, action };
import styles from "./q.settings.module.css";
@@ -253,8 +253,8 @@ function MapPicker() {
}
function VoiceChat() {
const { t } = useTranslation(["common", "q"]);
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
return (
<details>
@@ -263,128 +263,34 @@ function VoiceChat() {
<span>{t("q:settings.voiceChat.header")}</span> <Mic />
</div>
</summary>
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
<VoiceChatAbility />
<Languages />
<div>
<SubmitButton
size="big"
className="mt-2 mx-auto"
_action="UPDATE_VC"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateVoiceChatSchema}
defaultValues={{
vc: data.settings.vc,
languages: data.settings.languages ?? [],
}}
>
{({ FormField }) => (
<>
<FormField name="vc" />
<FormField name="languages" />
</>
)}
</SendouForm>
</div>
</details>
);
}
function VoiceChatAbility() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const label = (vc: Tables["User"]["vc"]) => {
switch (vc) {
case "YES":
return t("q:settings.voiceChat.canVC.yes");
case "NO":
return t("q:settings.voiceChat.canVC.no");
case "LISTEN_ONLY":
return t("q:settings.voiceChat.canVC.listenOnly");
default:
assertUnreachable(vc);
}
};
return (
<div className="stack">
<label>{t("q:settings.voiceChat.canVC.header")}</label>
{(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
return (
<div key={option} className="stack sm horizontal items-center">
<input
type="radio"
name="vc"
id={option}
value={option}
required
defaultChecked={data.settings.vc === option}
/>
<label htmlFor={option} className="mb-0 text-main-forced">
{label(option)}
</label>
</div>
);
})}
</div>
);
}
function Languages() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(data.settings.languages ?? []);
return (
<div className="stack">
<input type="hidden" name="languages" value={JSON.stringify(value)} />
<label>{t("q:settings.voiceChat.languages.header")}</label>
<select
className="w-max"
onChange={(e) => {
const newLanguages = [...value, e.target.value].sort((a, b) =>
a.localeCompare(b),
);
setValue(newLanguages);
}}
>
<option value="">
{t("q:settings.voiceChat.languages.placeholder")}
</option>
{languagesUnified
.filter((lang) => !value.includes(lang.code))
.map((option) => {
return (
<option key={option.code} value={option.code}>
{option.name}
</option>
);
})}
</select>
<div className="mt-2">
{value.map((code) => {
const name = languagesUnified.find((l) => l.code === code)?.name;
return (
<div key={code} className="stack horizontal items-center sm">
{name}{" "}
<SendouButton
icon={<X />}
variant="minimal-destructive"
onPress={() => {
const newLanguages = value.filter(
(codeInArr) => codeInArr !== code,
);
setValue(newLanguages);
}}
/>
</div>
);
})}
</div>
</div>
);
}
function WeaponPool() {
const { t } = useTranslation(["common", "q"]);
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const [weapons, setWeapons] = React.useState(data.settings.qWeaponPool ?? []);
const fetcher = useFetcher();
const latestWeapon = weapons[weapons.length - 1]?.weaponSplId ?? null;
const defaultWeaponPool = (data.settings.qWeaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
}));
return (
<details>
@@ -393,98 +299,16 @@ function WeaponPool() {
<span>{t("q:settings.weaponPool.header")}</span> <Puzzle />
</div>
</summary>
<fetcher.Form method="post" className="mb-4 stack items-center">
<input
type="hidden"
name="weaponPool"
value={JSON.stringify(weapons)}
/>
<div className={styles.weaponPoolSelectContainer}>
{weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
<WeaponSelect
onChange={(weaponSplId) => {
setWeapons([
...weapons,
{
weaponSplId,
isFavorite: 0,
},
]);
}}
// empty on selection
key={latestWeapon ?? "empty"}
disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
/>
) : (
<span className="text-xs text-info">
{t("q:settings.weaponPool.full")}
</span>
)}
</div>
<div className="stack horizontal md justify-center">
{weapons.map((weapon) => {
return (
<div key={weapon.weaponSplId} className="stack xs">
<div>
<WeaponImage
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
width={38}
height={38}
/>
</div>
<div className="stack sm horizontal items-center justify-center">
<SendouButton
icon={
<Star
className={weapon.isFavorite ? styles.starFilled : ""}
/>
}
variant="minimal"
aria-label="Favorite weapon"
onPress={() =>
setWeapons(
weapons.map((w) =>
w.weaponSplId === weapon.weaponSplId
? {
...weapon,
isFavorite: weapon.isFavorite === 1 ? 0 : 1,
}
: w,
),
)
}
/>
<SendouButton
icon={<Trash />}
variant="minimal-destructive"
aria-label="Delete weapon"
onPress={() =>
setWeapons(
weapons.filter(
(w) => w.weaponSplId !== weapon.weaponSplId,
),
)
}
data-testid={`delete-weapon-${weapon.weaponSplId}`}
size="small"
/>
</div>
</div>
);
})}
</div>
<div className="mt-6">
<SubmitButton
size="big"
className="mx-auto"
_action="UPDATE_SENDOUQ_WEAPON_POOL"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4">
<SendouForm
schema={updateWeaponPoolSchema}
defaultValues={{
weaponPool: defaultWeaponPool,
}}
>
{({ FormField }) => <FormField name="weaponPool" />}
</SendouForm>
</div>
</details>
);
}
@@ -682,40 +506,25 @@ function TrustedUsers() {
function Misc() {
const data = useLoaderData<typeof loader>();
const [checked, setChecked] = React.useState(Boolean(data.settings.noScreen));
const { t } = useTranslation(["common", "q", "weapons"]);
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
return (
<details>
<summary className={styles.summary}>
<div>{t("q:settings.misc.header")}</div>
</summary>
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
<div className="stack horizontal xs items-center">
<SendouSwitch
isSelected={checked}
onChange={setChecked}
id="noScreen"
name="noScreen"
/>
<label className="mb-0" htmlFor="noScreen">
{t("q:settings.avoid.label", {
special: t("weapons:SPECIAL_19"),
})}
</label>
</div>
<div className="mt-6">
<SubmitButton
size="big"
className="mx-auto"
_action="UPDATE_NO_SCREEN"
state={fetcher.state}
>
{t("common:actions.save")}
</SubmitButton>
</div>
</fetcher.Form>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.settings.noScreen),
}}
action={SETTINGS_PAGE}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</details>
);
}

View File

@@ -629,8 +629,8 @@ describe("SendouQ", () => {
const notes = await PrivateUserNoteRepository.byAuthorUserId(1);
const groups = SendouQ.lookingGroups(1, notes);
const replayGroup = groups.find((g) => g.members === undefined);
expect(replayGroup?.isReplay).toBe(true);
const fullGroups = groups.filter((g) => g.members === undefined);
expect(fullGroups.some((g) => g.isReplay)).toBe(true);
});
test("does not mark as replay when less than 3 members overlap", async () => {

View File

@@ -0,0 +1,18 @@
import { AsyncLocalStorage } from "node:async_hooks";
interface SessionIdContext {
sessionId: string | undefined;
}
export const sessionIdAsyncLocalStorage =
new AsyncLocalStorage<SessionIdContext>();
function getSessionId(): string | undefined {
return sessionIdAsyncLocalStorage.getStore()?.sessionId;
}
declare global {
var __getServerSessionId: (() => string | undefined) | undefined;
}
globalThis.__getServerSessionId = getSessionId;

View File

@@ -0,0 +1,17 @@
import { sessionIdAsyncLocalStorage } from "./session-id-context.server";
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
export const sessionIdMiddleware: MiddlewareFn = async ({ request }, next) => {
const sessionId = request.headers.get("Sendou-Session-Id") ?? undefined;
return sessionIdAsyncLocalStorage.run({ sessionId }, () => next());
};

View File

@@ -7,7 +7,6 @@ import { clampThemeToGamut } from "~/utils/oklch-gamut";
import {
errorToast,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsEditSchema } from "../settings-schemas";
@@ -62,5 +61,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
return successToast("Settings updated");
// TODO: removed temporarily, restore when we have better toasts
// (current problem is that when you update no screen from /q/settings, you get redirected to /settings)
// return successToast("Settings updated");
};

View File

@@ -10,12 +10,13 @@ import {
} from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { SendouSwitch } from "~/components/elements/Switch";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { SelectFormField } from "~/form/fields/SelectFormField";
import { SendouForm } from "~/form/SendouForm";
import { languages } from "~/modules/i18n/config";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@@ -25,6 +26,12 @@ import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
import { loader } from "../loaders/settings.server";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
updateNoScreenSchema,
} from "../settings-schemas";
import styles from "./settings.module.css";
import "./settings.global.css";
import type { ThemeInput } from "~/utils/oklch-gamut";
@@ -51,7 +58,17 @@ export default function SettingsPage() {
{t("common:settings.locales")}
</Divider>
<LanguageSelector />
{user ? <ClockFormatSelector /> : null}
{user ? (
<SendouForm
schema={clockFormatSchema}
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
<Divider className={styles.divider} smallText>
{t("common:settings.theme")}
</Divider>
@@ -64,36 +81,35 @@ export default function SettingsPage() {
</Divider>
<PushNotificationsEnabler />
<div className="mt-6 stack md">
<PreferenceSelectorSwitch
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
defaultSelected={
user?.preferences.disableBuildAbilitySorting ?? false
}
label={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
)}
bottomText={t(
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
)}
/>
<PreferenceSelectorSwitch
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
defaultSelected={
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
}
label={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
)}
bottomText={t(
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
)}
/>
<PreferenceSelectorSwitch
_action="UPDATE_NO_SCREEN"
defaultSelected={Boolean(data.noScreen)}
label={t("common:settings.UPDATE_NO_SCREEN.label")}
bottomText={t("common:settings.UPDATE_NO_SCREEN.bottomText")}
/>
<SendouForm
schema={disableBuildAbilitySortingSchema}
defaultValues={{
newValue:
user.preferences.disableBuildAbilitySorting ?? false,
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={disallowScrimPickupsFromUntrustedSchema}
defaultValues={{
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.noScreen),
}}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
<form method="post" action={LOG_OUT_URL} className="mt-6">
<SendouButton
@@ -125,28 +141,23 @@ function LanguageSelector() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newLang = event.target.value;
const languageItems = languages.map((lang) => ({
value: lang.code,
label: lang.name,
}));
const handleLanguageChange = (newLang: string | null) => {
if (!newLang) return;
navigate(`?${addUniqueParam(searchParams, "lng", newLang).toString()}`);
};
return (
<div>
<Label htmlFor="lang">{t("common:header.language")}</Label>
<select
id="lang"
defaultValue={i18n.language}
onChange={handleLanguageChange}
>
{languages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
<SelectFormField
label={t("common:header.language")}
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}
/>
);
}
@@ -165,30 +176,25 @@ function ThemeSelector() {
const { t } = useTranslation(["common"]);
const { userTheme, setUserTheme } = useTheme();
const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (!document.startViewTransition) {
setUserTheme(event.target.value as Theme);
return;
}
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
(theme) => ({
value: theme,
label: t(`common:theme.${theme}`),
}),
);
document.startViewTransition(() =>
setUserTheme(event.target.value as Theme),
);
const handleThemeChange = (newTheme: string | null) => {
if (!newTheme) return;
setUserTheme(newTheme as Theme);
};
return (
<div>
<Label htmlFor="theme">{t("common:header.theme")}</Label>
<select id="theme" defaultValue={userTheme ?? "auto"} onChange={onChange}>
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
return (
<option key={theme} value={theme}>
{t(`common:theme.${theme}`)}
</option>
);
})}
</select>
</div>
<SelectFormField
label={t("common:header.theme")}
items={themeItems}
value={userTheme ?? "auto"}
onChange={handleThemeChange}
/>
);
}
@@ -224,38 +230,6 @@ function CustomColorSelector() {
);
}
function ClockFormatSelector() {
const { t } = useTranslation(["common"]);
const user = useUser();
const fetcher = useFetcher();
const handleClockFormatChange = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newFormat = event.target.value as "auto" | "24h" | "12h";
fetcher.submit(
{ _action: "UPDATE_CLOCK_FORMAT", newValue: newFormat },
{ method: "post", encType: "application/json" },
);
};
return (
<div>
<Label htmlFor="clock-format">{t("common:settings.clockFormat")}</Label>
<select
id="clock-format"
defaultValue={user?.preferences.clockFormat ?? "auto"}
onChange={handleClockFormatChange}
disabled={fetcher.state !== "idle"}
>
<option value="auto">{t("common:clockFormat.auto")}</option>
<option value="24h">{t("common:clockFormat.24h")}</option>
<option value="12h">{t("common:clockFormat.12h")}</option>
</select>
</div>
);
}
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);
@@ -346,38 +320,3 @@ function PushNotificationsEnabler() {
</div>
);
}
function PreferenceSelectorSwitch({
_action,
label,
bottomText,
defaultSelected,
}: {
_action: string;
label: string;
bottomText: string;
defaultSelected: boolean;
}) {
const fetcher = useFetcher();
const onChange = (isSelected: boolean) => {
fetcher.submit(
{ _action, newValue: isSelected },
{ method: "post", encType: "application/json" },
);
};
return (
<div>
<SendouSwitch
defaultSelected={defaultSelected}
onChange={onChange}
isDisabled={fetcher.state !== "idle"}
data-testid={`${_action}-switch`}
>
{label}
</SendouSwitch>
<FormMessage type="info">{bottomText}</FormMessage>
</div>
);
}

View File

@@ -1,27 +1,45 @@
import { z } from "zod";
import { _action, themeInputSchema } from "~/utils/zod";
import { select, stringConstant, toggle } from "~/form/fields";
export { themeInputSchema };
export const clockFormatSchema = z.object({
_action: stringConstant("UPDATE_CLOCK_FORMAT"),
newValue: select({
label: "labels.clockFormat",
items: [
{ value: "auto", label: "options.clockFormat.auto" },
{ value: "24h", label: "options.clockFormat.24h" },
{ value: "12h", label: "options.clockFormat.12h" },
],
}),
});
export const disableBuildAbilitySortingSchema = z.object({
_action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: toggle({
label: "labels.disableBuildAbilitySorting",
bottomText: "bottomTexts.disableBuildAbilitySorting",
}),
});
export const disallowScrimPickupsFromUntrustedSchema = z.object({
_action: stringConstant("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: toggle({
label: "labels.disallowScrimPickupsFromUntrusted",
bottomText: "bottomTexts.disallowScrimPickupsFromUntrusted",
}),
});
export const updateNoScreenSchema = z.object({
_action: stringConstant("UPDATE_NO_SCREEN"),
newValue: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",
}),
});
export const settingsEditSchema = z.union([
z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: themeInputSchema.nullable(),
}),
z.object({
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: z.boolean(),
}),
z.object({
_action: _action("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: z.boolean(),
}),
z.object({
_action: _action("UPDATE_NO_SCREEN"),
newValue: z.boolean(),
}),
z.object({
_action: _action("UPDATE_CLOCK_FORMAT"),
newValue: z.enum(["auto", "24h", "12h"]),
}),
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
updateNoScreenSchema,
clockFormatSchema,
]);

View File

@@ -12,6 +12,7 @@ import {
concatUserSubmittedImagePrefix,
tournamentLogoOrNull,
} from "~/utils/kysely.server";
import { mySlugify } from "~/utils/urls";
export function findAllUndisbanded() {
return db
@@ -266,20 +267,22 @@ export async function teamsByMemberUserId(
}
export async function create(
args: Pick<Insertable<Tables["Team"]>, "name" | "customUrl"> & {
args: Pick<Insertable<Tables["Team"]>, "name"> & {
ownerUserId: number;
isMainTeam: boolean;
},
) {
const customUrl = mySlugify(args.name);
return db.transaction().execute(async (trx) => {
const team = await trx
.insertInto("AllTeam")
.values({
name: args.name,
customUrl: args.customUrl,
customUrl,
inviteCode: shortNanoid(),
})
.returning("id")
.returning(["id", "customUrl"])
.executeTakeFirstOrThrow();
await trx
@@ -291,22 +294,25 @@ export async function create(
isMainTeam: Number(args.isMainTeam),
})
.execute();
return team;
});
}
export async function update({
id,
name,
customUrl,
bio,
bsky,
tag,
customTheme,
}: Pick<
Insertable<Tables["Team"]>,
"id" | "name" | "customUrl" | "bio" | "bsky" | "tag"
"id" | "name" | "bio" | "bsky" | "tag"
> & { customTheme: CustomTheme | null }) {
return db
const customUrl = mySlugify(name);
const team = await db
.updateTable("AllTeam")
.set({
name,
@@ -319,6 +325,8 @@ export async function update({
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
return team;
}
export function switchMainTeam({

View File

@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamProfileAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {
_action: "EDIT",
name: "Team 1",
bio: "",
bsky: "",
tag: "",
} as const;
describe("team page editing", () => {
beforeEach(async () => {
await dbInsertUsers();
await createTeamAction({ name: "Team 1" }, { user: "regular" });
});
afterEach(() => {
dbReset();
});
it("adds valid custom css vars", async () => {
const response = await editTeamProfileAction(
{
css: JSON.stringify({ bg: "#fff" }),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(response.status).toBe(302);
});
it("prevents adding custom css var of unknown property", async () => {
const response = await editTeamProfileAction(
{
css: JSON.stringify({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
it("prevents adding custom css var of unknown value", async () => {
const response = await editTeamProfileAction(
{
css: JSON.stringify({
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
});

View File

@@ -55,18 +55,19 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
errorToastIfFalsy(
newCustomUrl.length > 0,
"Team name can't be only special characters",
);
// can't take someone else's custom url
if (existingTeam && existingTeam.id !== team.id) {
return {
errors: ["forms.errors.duplicateName"],
};
const teams = await TeamRepository.findAllUndisbanded();
const duplicateTeam = teams.find(
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
);
if (duplicateTeam) {
return { errors: ["forms:errors.duplicateName"] };
}
const customTheme =
@@ -74,14 +75,13 @@ export const action: ActionFunction = async ({ request, params }) => {
? clampThemeToGamut(data.customTheme)
: null;
const editedTeam = await TeamRepository.update({
const updatedTeam = await TeamRepository.update({
id: team.id,
customUrl: newCustomUrl,
...data,
customTheme,
});
throw redirect(teamPage(editedTeam.customUrl));
throw redirect(teamPage(updatedTeam.customUrl));
}
default: {
assertUnreachable(data);

View File

@@ -1,15 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas";
const action = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
describe("team creation", () => {
@@ -24,12 +20,12 @@ describe("team creation", () => {
await action({ name: "Team 1" }, { user: "regular" });
const res = await action({ name: "Team 1" }, { user: "regular" });
expect(res.errors[0]).toBe("forms.errors.duplicateName");
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
});
it("prevents creating a team whose name is only special characters", async () => {
const response = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
const res = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
assertResponseErrored(response);
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
});
});

View File

@@ -1,19 +1,26 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { mySlugify, teamPage } from "~/utils/urls";
import { parseFormData } from "~/form/parse.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { TEAM } from "../team-constants";
import { createTeamSchema } from "../team-schemas.server";
import { createTeamSchemaServer } from "../team-schemas.server";
export const action: ActionFunction = async ({ request }) => {
const user = requireUser();
const data = await parseRequestPayload({
const result = await parseFormData({
request,
schema: createTeamSchema,
schema: createTeamSchemaServer,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
const teams = await TeamRepository.findAllUndisbanded();
const currentTeamCount = teams.filter((team) =>
@@ -28,26 +35,11 @@ export const action: ActionFunction = async ({ request }) => {
"Already in max amount of teams",
);
// two teams can't have same customUrl
const customUrl = mySlugify(data.name);
errorToastIfFalsy(
customUrl.length > 0,
"Team name can't be only special characters",
);
if (teams.some((team) => team.customUrl === customUrl)) {
return {
errors: ["forms.errors.duplicateName"],
};
}
await TeamRepository.create({
const team = await TeamRepository.create({
ownerUserId: user.id,
name: data.name,
customUrl,
isMainTeam: currentTeamCount === 0,
});
throw redirect(teamPage(customUrl));
throw redirect(teamPage(team.customUrl));
};

View File

@@ -7,14 +7,17 @@ import {
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {
@@ -42,7 +45,7 @@ describe("team creation", () => {
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(res.errors[0]).toBe("forms.errors.duplicateName");
expect(res.errors[0]).toBe("forms:errors.duplicateName");
});
it("prevents editing team name to only special characters", async () => {

View File

@@ -14,8 +14,8 @@ import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import * as TeamRepository from "../TeamRepository.server";
import type { createTeamSchema } from "../team-schemas";
import type {
createTeamSchema,
editTeamSchema,
teamProfilePageActionSchema,
} from "../team-schemas.server";
@@ -28,12 +28,15 @@ const loadUserTeamLoader = wrappedLoader<
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
action: _teamPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
async function loadTeams() {

View File

@@ -2,16 +2,15 @@ import { Search } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Form, Link, useLoaderData, useSearchParams } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { AddNewButton } from "~/components/AddNewButton";
import { Alert } from "~/components/Alert";
import { SendouDialog } from "~/components/elements/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 { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form";
import { usePagination } from "~/hooks/usePagination";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
@@ -25,6 +24,7 @@ import {
import { action } from "../actions/t.server";
import { loader } from "../loaders/t.server";
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";
import { createTeamSchema } from "../team-schemas";
export { loader, action };
import styles from "../team.module.css";
@@ -40,7 +40,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["team"],
i18n: ["team", "forms"],
breadcrumb: () => ({
imgPath: navIconUrl("t"),
href: TEAM_SEARCH_PAGE,
@@ -193,23 +193,9 @@ function NewTeamDialog() {
isOpen={isOpen}
onCloseTo={TEAM_SEARCH_PAGE}
>
<Form method="post" className="stack md">
<div className="">
<label htmlFor="name">{t("common:forms.name")}</label>
<input
id="name"
name="name"
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
required
data-testid={isOpen ? "new-team-name-input" : undefined}
/>
</div>
<FormErrors namespace="team" />
<div className="mt-2">
<SubmitButton>{t("common:actions.create")}</SubmitButton>
</div>
</Form>
<SendouForm schema={createTeamSchema}>
{({ FormField }) => <FormField name="name" />}
</SendouForm>
</SendouDialog>
);
}

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { mySlugify } from "~/utils/urls";
import {
_action,
falsyToNull,
@@ -6,17 +7,25 @@ import {
safeStringSchema,
themeInputSchema,
} from "~/utils/zod";
import * as TeamRepository from "./TeamRepository.server";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
import { createTeamSchema } from "./team-schemas";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
name: createTeamSchema.shape.name.refine(
async (name) => {
const teams = await TeamRepository.findAllUndisbanded();
const customUrl = mySlugify(name);
return !teams.some((team) => team.customUrl === customUrl);
},
{ message: "forms:errors.duplicateName" },
),
});
export const teamParamsSchema = z.object({ customUrl: z.string() });
export const createTeamSchema = z.object({
name: safeStringSchema({
min: TEAM.NAME_MIN_LENGTH,
max: TEAM.NAME_MAX_LENGTH,
}),
});
export const teamProfilePageActionSchema = z.union([
z.object({
_action: _action("LEAVE_TEAM"),

View File

@@ -0,0 +1,17 @@
import { z } from "zod";
import { textFieldRequired } from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { TEAM } from "./team-constants";
export const createTeamSchema = z.object({
name: textFieldRequired({
label: "labels.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: {
func: (teamName) =>
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
message: "forms:errors.noOnlySpecialCharacters",
},
}),
});

View File

@@ -54,7 +54,8 @@
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6)
), var(--team-banner-img);
),
var(--team-banner-img);
background-size: cover;
width: 100%;
aspect-ratio: 2 / 1;

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