diff --git a/.claude/settings.json b/.claude/settings.json
index 5b5d98b06..7e05cd744 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -6,5 +6,8 @@
"PreCompact": [
{ "hooks": [{ "type": "command", "command": "beans prime" }] }
]
+ },
+ "enabledPlugins": {
+ "code-review@claude-plugins-official": true
}
}
diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index 544eb0564..8e7304398 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -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:
diff --git a/.gitignore b/.gitignore
index 00032c0c3..b967093a7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/AGENTS.md b/AGENTS.md
index 76656c4ff..6cabdd4d7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx
index 013d7ad60..3e0a946be 100644
--- a/app/components/Catcher.tsx
+++ b/app/components/Catcher.tsx
@@ -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() {
Error {error.status}
- 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:
- {error.data ? (
- {JSON.stringify(JSON.parse(error.data), null, 2)}
- ) : null}
+
+ Session ID: {getSessionId()}
+ {error.data
+ ? `\n${JSON.stringify(JSON.parse(error.data), null, 2)}`
+ : null}
+
);
}
diff --git a/app/components/Draggable.tsx b/app/components/Draggable.tsx
deleted file mode 100644
index 6cf427563..000000000
--- a/app/components/Draggable.tsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-}
diff --git a/app/components/FormMessage.module.css b/app/components/FormMessage.module.css
index 36e79e71c..c21016ea2 100644
--- a/app/components/FormMessage.module.css
+++ b/app/components/FormMessage.module.css
@@ -11,3 +11,7 @@
font-size: var(--fonts-xs);
margin-block-start: var(--label-margin);
}
+
+.noMargin {
+ margin-block-start: 0;
+}
diff --git a/app/components/FormMessage.tsx b/app/components/FormMessage.tsx
index 9693e4e67..54562586a 100644
--- a/app/components/FormMessage.tsx
+++ b/app/components/FormMessage.tsx
@@ -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 (
diff --git a/app/components/WeaponSelect.module.css b/app/components/WeaponSelect.module.css
index f76f663ff..ee4092d3b 100644
--- a/app/components/WeaponSelect.module.css
+++ b/app/components/WeaponSelect.module.css
@@ -1,3 +1,7 @@
+.selectWidthWider {
+ --select-width: 100%;
+}
+
.item {
display: flex;
gap: var(--s-2);
diff --git a/app/components/WeaponSelect.tsx b/app/components/WeaponSelect.tsx
index f8f2f433d..3ec0ebd3f 100644
--- a/app/components/WeaponSelect.tsx
+++ b/app/components/WeaponSelect.tsx
@@ -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
;
+ isDisabled?: boolean;
+ placeholder?: string;
}
export function WeaponSelect<
@@ -62,11 +64,20 @@ export function WeaponSelect<
testId = "weapon-select",
isRequired,
quickSelectWeaponsIds,
+ isDisabled,
+ placeholder,
}: WeaponSelectProps) {
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;
+ 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(
diff --git a/app/components/elements/BottomTexts.tsx b/app/components/elements/BottomTexts.tsx
index 72a75984c..4837cf91a 100644
--- a/app/components/elements/BottomTexts.tsx
+++ b/app/components/elements/BottomTexts.tsx
@@ -4,18 +4,20 @@ import { SendouFieldMessage } from "~/components/elements/FieldMessage";
export function SendouBottomTexts({
bottomText,
errorText,
+ errorId,
}: {
bottomText?: string;
errorText?: string;
+ errorId?: string;
}) {
return (
<>
{errorText ? (
- {errorText}
+ {errorText}
) : (
)}
- {bottomText && !errorText ? (
+ {bottomText ? (
{bottomText}
) : null}
>
diff --git a/app/components/elements/DatePicker.tsx b/app/components/elements/DatePicker.tsx
index c2800fa59..f7951a9cd 100644
--- a/app/components/elements/DatePicker.tsx
+++ b/app/components/elements/DatePicker.tsx
@@ -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
label: string;
bottomText?: string;
errorText?: string;
+ errorId?: string;
}
export function SendouDatePicker({
label,
errorText,
+ errorId,
bottomText,
isRequired,
...rest
}: SendouDatePickerProps) {
+ const isMounted = useIsMounted();
+
+ if (!isMounted) {
+ return (
+
+ {label}
+
+
+
+ );
+ }
+
return (
({
-
+
diff --git a/app/components/elements/FieldError.tsx b/app/components/elements/FieldError.tsx
index 2162de14e..7ea32e06e 100644
--- a/app/components/elements/FieldError.tsx
+++ b/app/components/elements/FieldError.tsx
@@ -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 (
-
+
{children}
);
diff --git a/app/components/elements/UserSearch.tsx b/app/components/elements/UserSearch.tsx
index 5913cc923..597f344be 100644
--- a/app/components/elements/UserSearch.tsx
+++ b/app/components/elements/UserSearch.tsx
@@ -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 ? (
diff --git a/app/components/form/AddFieldButton.tsx b/app/components/form/AddFieldButton.tsx
deleted file mode 100644
index d0f08a68b..000000000
--- a/app/components/form/AddFieldButton.tsx
+++ /dev/null
@@ -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 (
- }
- aria-label="Add form field"
- size="small"
- variant="minimal"
- onPress={onClick}
- className="self-start"
- data-testid="add-field-button"
- >
- {t("common:actions.add")}
-
- );
-}
diff --git a/app/components/form/DateFormField.tsx b/app/components/form/DateFormField.tsx
deleted file mode 100644
index 475d4dd61..000000000
--- a/app/components/form/DateFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
- required,
- granularity = "day",
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
- required?: boolean;
- granularity?: "day" | "minute";
-}) {
- const methods = useFormContext();
-
- return (
- {
- 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 (
- {
- 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}
- />
- );
- }}
- />
- );
-}
diff --git a/app/components/form/FormFieldset.tsx b/app/components/form/FormFieldset.tsx
deleted file mode 100644
index ac84a82be..000000000
--- a/app/components/form/FormFieldset.tsx
+++ /dev/null
@@ -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 (
-
- {title}
-
-
- );
-}
diff --git a/app/components/form/InputFormField.tsx b/app/components/form/InputFormField.tsx
deleted file mode 100644
index 109fc0e70..000000000
--- a/app/components/form/InputFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
- placeholder,
- required,
- type,
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
- placeholder?: string;
- required?: boolean;
- type?: React.HTMLInputTypeAttribute;
-}) {
- const methods = useFormContext();
- const id = React.useId();
-
- const error = get(methods.formState.errors, name);
-
- return (
-
-
- {label}
-
-
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
diff --git a/app/components/form/InputGroupFormField.tsx b/app/components/form/InputGroupFormField.tsx
deleted file mode 100644
index 07c0ed7a1..000000000
--- a/app/components/form/InputGroupFormField.tsx
+++ /dev/null
@@ -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 {
- label: string;
- name: FieldPath;
- bottomText?: string;
- direction?: "horizontal" | "vertical";
- type: "checkbox" | "radio";
- values: Array<{
- label: string;
- value: string;
- }>;
-}
-
-export function InputGroupFormField({
- label,
- name,
- bottomText,
- values,
- type,
- direction = "vertical",
-}: InputGroupFormFieldProps) {
- const methods = useFormContext();
-
- return (
- {
- 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 (
-
-
- {label}
-
- {values.map((checkbox) => {
- const isChecked = value?.includes(checkbox.value);
-
- return (
-
- {checkbox.label}
-
- );
- })}
-
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
- }}
- />
- );
-}
-
-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 (
-
- onChange(e.target.checked)}
- />
-
- {children}
-
-
- );
-}
diff --git a/app/components/form/RemoveFieldButton.tsx b/app/components/form/RemoveFieldButton.tsx
deleted file mode 100644
index 2d75f9bd4..000000000
--- a/app/components/form/RemoveFieldButton.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Trash } from "lucide-react";
-import { SendouButton } from "../elements/Button";
-
-export function RemoveFieldButton({ onClick }: { onClick: () => void }) {
- return (
- }
- aria-label="Remove form field"
- size="small"
- variant="minimal-destructive"
- onPress={onClick}
- />
- );
-}
diff --git a/app/components/form/SelectFormField.tsx b/app/components/form/SelectFormField.tsx
deleted file mode 100644
index e7dc898e3..000000000
--- a/app/components/form/SelectFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- values,
- bottomText,
- required,
-}: {
- label: string;
- name: FieldPath;
- 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 (
-
-
- {label}
-
-
- {values.map((option) => (
-
- {option.label}
-
- ))}
-
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
diff --git a/app/components/form/SendouForm.tsx b/app/components/form/SendouForm.tsx
deleted file mode 100644
index 3f46dad97..000000000
--- a/app/components/form/SendouForm.tsx
+++ /dev/null
@@ -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({
- schema,
- defaultValues,
- heading,
- children,
- cancelLink,
- submitButtonTestId,
-}: {
- schema: T;
- defaultValues?: DefaultValues>;
- heading?: string;
- children: React.ReactNode;
- cancelLink?: string;
- submitButtonTestId?: string;
-}) {
- const { t } = useTranslation(["common"]);
- const fetcher = useFetcher();
- 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[0], {
- method: "post",
- encType: "application/json",
- }),
- ),
- [],
- );
-
- return (
-
-
- {heading ? {heading} : null}
- {children}
-
-
- {t("common:actions.submit")}
-
- {cancelLink ? (
-
- {t("common:actions.cancel")}
-
- ) : null}
-
-
-
- );
-}
diff --git a/app/components/form/TextAreaFormField.tsx b/app/components/form/TextAreaFormField.tsx
deleted file mode 100644
index 772194e20..000000000
--- a/app/components/form/TextAreaFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
- maxLength,
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
- maxLength: number;
-}) {
- const methods = useFormContext();
- const value = useWatch({ name }) ?? "";
- const id = React.useId();
-
- const error = get(methods.formState.errors, name);
-
- return (
-
-
- {label}
-
-
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
diff --git a/app/components/form/TextArrayFormField.tsx b/app/components/form/TextArrayFormField.tsx
deleted file mode 100644
index 44cb1a545..000000000
--- a/app/components/form/TextArrayFormField.tsx
+++ /dev/null
@@ -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({
- 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;
- bottomText?: string;
- format?: "plain" | "object";
-}) {
- const {
- register,
- formState: { errors },
- clearErrors,
- } = useFormContext();
- const { fields, append, remove } = useFieldArray({
- name,
- });
-
- const rootError = errors[name]?.root;
-
- return (
-
-
{label}
-
- {fields.map((field, index) => {
- // @ts-expect-error
- const error = errors[name]?.[index]?.value;
-
- return (
-
-
-
- {
- remove(index);
- clearErrors(`${name}.root`);
- }}
- />
-
- {error && (
-
- {error.message as string}
-
- )}
-
- );
- })}
-
append(format === "plain" ? "" : { value: "" })}
- />
- {rootError && (
- {rootError.message as string}
- )}
- {bottomText && !rootError ? (
- {bottomText}
- ) : null}
-
-
- );
-}
diff --git a/app/components/form/ToggleFormField.tsx b/app/components/form/ToggleFormField.tsx
deleted file mode 100644
index 42a0648bf..000000000
--- a/app/components/form/ToggleFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
-}) {
- const methods = useFormContext();
- const id = React.useId();
-
- const error = get(methods.formState.errors, name);
-
- return (
-
- {label}
- (
-
- )}
- />
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
diff --git a/app/components/form/UserSearchFormField.tsx b/app/components/form/UserSearchFormField.tsx
deleted file mode 100644
index 840b267a7..000000000
--- a/app/components/form/UserSearchFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
-}) {
- const methods = useFormContext();
-
- const error = get(methods.formState.errors, name);
-
- return (
-
- (
- onChange(newUser?.id)}
- initialUserId={value}
- onBlur={onBlur}
- ref={ref}
- label={label}
- />
- )}
- />
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts
index 02468f5cb..996ac12d2 100644
--- a/app/db/seed/index.ts
+++ b/app/db/seed/index.ts
@@ -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) {
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,
+ });
+ }
+}
diff --git a/app/db/tables.ts b/app/db/tables.ts
index 5c45028a0..db8ba95e5 100644
--- a/app/db/tables.ts
+++ b/app/db/tables.ts
@@ -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;
+ /** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
+ seedingSnapshot: JSONColumnTypeNullable;
+}
+
+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;
}
+export interface LiveStream {
+ id: GeneratedAlways;
+ userId: number | null;
+ viewerCount: number;
+ thumbnailUrl: string;
+ twitch: string | null;
+}
+
export interface BanLog {
id: GeneratedAlways;
userId: number;
@@ -1163,6 +1181,7 @@ export interface DB {
AllTeamMember: TeamMember;
ApiToken: ApiToken;
Art: Art;
+ LiveStream: LiveStream;
ArtTag: ArtTag;
ArtUserMetadata: ArtUserMetadata;
TaggedArt: TaggedArt;
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index c6ad81199..563a1553a 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -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", () => {
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index e6d5e79ca..b8c639337 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -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) => {
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);
+}
diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts
index 9baf31897..2b9fbec85 100644
--- a/app/features/admin/routes/admin.test.ts
+++ b/app/features/admin/routes/admin.test.ts
@@ -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,
diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts
index d8a41d1ee..e544f3628 100644
--- a/app/features/api-private/routes/seed.ts
+++ b/app/features/api-private/routes/seed.ts
@@ -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) {
diff --git a/app/features/api-public/api-cors-middleware.server.ts b/app/features/api-public/api-cors-middleware.server.ts
new file mode 100644
index 000000000..965761b92
--- /dev/null
+++ b/app/features/api-public/api-cors-middleware.server.ts
@@ -0,0 +1,45 @@
+type MiddlewareArgs = {
+ request: Request;
+ context: unknown;
+};
+
+type MiddlewareFn = (
+ args: MiddlewareArgs,
+ next: () => Promise,
+) => Promise;
+
+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,
+ });
+};
diff --git a/app/features/api-public/api-public-utils.server.ts b/app/features/api-public/api-public-utils.server.ts
index cb8252456..71facd609 100644
--- a/app/features/api-public/api-public-utils.server.ts
+++ b/app/features/api-public/api-public-utils.server.ts
@@ -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,
- });
- }
-}
diff --git a/app/features/api-public/routes/calendar.$year.$week.ts b/app/features/api-public/routes/calendar.$year.$week.ts
index 6b33fd42e..70f8f2419 100644
--- a/app/features/api-public/routes/calendar.$year.$week.ts
+++ b/app/features/api-public/routes/calendar.$year.$week.ts
@@ -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 }) {
diff --git a/app/features/api-public/routes/org.$id.ts b/app/features/api-public/routes/org.$id.ts
index 6eb579a47..6193e1674 100644
--- a/app/features/api-public/routes/org.$id.ts
+++ b/app/features/api-public/routes/org.$id.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/sendouq.active-match.$userId.ts b/app/features/api-public/routes/sendouq.active-match.$userId.ts
index 309554c6b..6a7331b1d 100644
--- a/app/features/api-public/routes/sendouq.active-match.$userId.ts
+++ b/app/features/api-public/routes/sendouq.active-match.$userId.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/sendouq.match.$matchId.ts b/app/features/api-public/routes/sendouq.match.$matchId.ts
index a5a685af3..9fff6e49b 100644
--- a/app/features/api-public/routes/sendouq.match.$matchId.ts
+++ b/app/features/api-public/routes/sendouq.match.$matchId.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/team.$id.ts b/app/features/api-public/routes/team.$id.ts
index 74b057fea..59c34aea7 100644
--- a/app/features/api-public/routes/team.$id.ts
+++ b/app/features/api-public/routes/team.$id.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts
index 5c147d008..26946feb2 100644
--- a/app/features/api-public/routes/tournament-match.$id.ts
+++ b/app/features/api-public/routes/tournament-match.$id.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts
index 9c533d63f..48bd0c7fb 100644
--- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts
+++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts
index 4151ca751..e79e1a4d5 100644
--- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts
+++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts
@@ -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) {
diff --git a/app/features/api-public/routes/tournament.$id.casted.ts b/app/features/api-public/routes/tournament.$id.casted.ts
index 7a40750b1..e53d0278d 100644
--- a/app/features/api-public/routes/tournament.$id.casted.ts
+++ b/app/features/api-public/routes/tournament.$id.casted.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/tournament.$id.players.ts b/app/features/api-public/routes/tournament.$id.players.ts
index 1222d23d7..c8aaedd47 100644
--- a/app/features/api-public/routes/tournament.$id.players.ts
+++ b/app/features/api-public/routes/tournament.$id.players.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts
index 17d8f3f2e..69533a16c 100644
--- a/app/features/api-public/routes/tournament.$id.teams.ts
+++ b/app/features/api-public/routes/tournament.$id.teams.ts
@@ -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)[]) {
diff --git a/app/features/api-public/routes/tournament.$id.ts b/app/features/api-public/routes/tournament.$id.ts
index aeb97bf9d..3b9718d5c 100644
--- a/app/features/api-public/routes/tournament.$id.ts
+++ b/app/features/api-public/routes/tournament.$id.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/user.$identifier.ids.ts b/app/features/api-public/routes/user.$identifier.ids.ts
index a737bc9fa..833f0610c 100644
--- a/app/features/api-public/routes/user.$identifier.ids.ts
+++ b/app/features/api-public/routes/user.$identifier.ids.ts
@@ -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);
};
diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts
index 54ce2f7fe..e6a29ae09 100644
--- a/app/features/api-public/routes/user.$identifier.ts
+++ b/app/features/api-public/routes/user.$identifier.ts
@@ -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);
};
diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts
index 680dc28e3..73c382e44 100644
--- a/app/features/api-public/schema.ts
+++ b/app/features/api-public/schema.ts
@@ -121,6 +121,7 @@ export interface GetSendouqMatchResponse {
}
type SendouqMatchTeam = {
+ id: number;
score: number;
players: Array;
};
diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts
index 9215b5d30..8754898e9 100644
--- a/app/features/art/actions/art.new.server.ts
+++ b/app/features/art/actions/art.new.server.ts
@@ -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);
diff --git a/app/features/associations/AssociationRepository.server.ts b/app/features/associations/AssociationRepository.server.ts
index 1252b472c..5dd0b5095 100644
--- a/app/features/associations/AssociationRepository.server.ts
+++ b/app/features/associations/AssociationRepository.server.ts
@@ -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().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");
+ }
});
}
diff --git a/app/features/associations/actions/associations.new.server.ts b/app/features/associations/actions/associations.new.server.ts
index 67582497e..cdbc51cce 100644
--- a/app/features/associations/actions/associations.new.server.ts
+++ b/app/features/associations/actions/associations.new.server.ts
@@ -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({
- 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());
};
diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts
index 5d5c2ae74..288dde12f 100644
--- a/app/features/associations/associations-schemas.ts
+++ b/app/features/associations/associations-schemas.ts
@@ -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({
diff --git a/app/features/associations/routes/associations.new.tsx b/app/features/associations/routes/associations.new.tsx
index ecfebf116..ab3769570 100644
--- a/app/features/associations/routes/associations.new.tsx
+++ b/app/features/associations/routes/associations.new.tsx
@@ -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;
-
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()}
>
-
-
- label={t("scrims:associations.forms.name.title")}
- name="name"
- />
+
+ {({ FormField }) => }
);
diff --git a/app/features/badges/components/BadgesSelector.tsx b/app/features/badges/components/BadgesSelector.tsx
index 772c92bd4..13f312ba3 100644
--- a/app/features/badges/components/BadgesSelector.tsx
+++ b/app/features/badges/components/BadgesSelector.tsx
@@ -46,23 +46,29 @@ export function BadgesSelector({
)}
{showSelect ? (
-
- onChange([...selectedBadges, Number(e.target.value)])
- }
- disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
- data-testid="badges-selector"
- >
- {t("common:badges.selector.select")}
- {options
- .filter((badge) => !selectedBadges.includes(badge.id))
- .map((badge) => (
-
- {badge.displayName}
-
- ))}
-
+ options.length === 0 ? (
+
+ {t("common:badges.selector.noneAvailable")}
+
+ ) : (
+ onBlur?.()}
+ onChange={(e) =>
+ onChange([...selectedBadges, Number(e.target.value)])
+ }
+ disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
+ data-testid="badges-selector"
+ >
+ {t("common:badges.selector.select")}
+ {options
+ .filter((badge) => !selectedBadges.includes(badge.id))
+ .map((badge) => (
+
+ {badge.displayName}
+
+ ))}
+
+ )
) : null}
);
diff --git a/app/features/badges/homemade.json b/app/features/badges/homemade.json
index 03778f379..d7475e5a6 100644
--- a/app/features/badges/homemade.json
+++ b/app/features/badges/homemade.json
@@ -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": {
diff --git a/app/features/builds/BuildRepository.server.ts b/app/features/builds/BuildRepository.server.ts
index 961e5965c..1e5394d37 100644
--- a/app/features/builds/BuildRepository.server.ts
+++ b/app/features/builds/BuildRepository.server.ts
@@ -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 | null;
- headGearSplId: TablesInsertable["Build"]["headGearSplId"];
- clothesGearSplId: TablesInsertable["Build"]["clothesGearSplId"];
- shoesGearSplId: TablesInsertable["Build"]["shoesGearSplId"];
+ headGearSplId: number | null;
+ clothesGearSplId: number | null;
+ shoesGearSplId: number | null;
weaponSplIds: Array;
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().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>;
+
+ 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();
+ type BuildRow = Awaited>[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) {
diff --git a/app/features/builds/builds-constants.ts b/app/features/builds/builds-constants.ts
index aa971a889..5144d2300 100644
--- a/app/features/builds/builds-constants.ts
+++ b/app/features/builds/builds-constants.ts
@@ -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;
diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts
index be06b618d..0a64016f8 100644
--- a/app/features/calendar/actions/calendar.new.server.ts
+++ b/app/features/calendar/actions/calendar.new.server.ts
@@ -40,7 +40,6 @@ export const action: ActionFunction = async ({ request }) => {
const data = await parseFormData({
formData,
schema: newCalendarEventActionSchema,
- parseAsync: true,
});
const isEditing = Boolean(data.eventToEditId);
diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts
index 03038f7d1..8252df7d0 100644
--- a/app/features/calendar/calendar-schemas.ts
+++ b/app/features/calendar/calendar-schemas.ts
@@ -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 (
diff --git a/app/features/calendar/components/FiltersDialog.tsx b/app/features/calendar/components/FiltersDialog.tsx
index 6389b1c3b..112eac2ee 100644
--- a/app/features/calendar/components/FiltersDialog.tsx
+++ b/app/features/calendar/components/FiltersDialog.tsx
@@ -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;
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 = [
- "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();
+ 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[0], {
- method: "post",
- encType: "application/json",
- }),
- ),
- [],
- );
-
return (
-
-
-
- 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" },
- ]}
- />
-
-
- label={t("calendar:filter.exactModes")}
- name={"modesExact" as const}
- bottomText={t("calendar:filter.exactModesBottom")}
- />
-
-
- 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" },
- ]}
- />
-
-
- 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" },
- ]}
- />
-
-
- 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" },
- ]}
- />
-
-
- label={t("calendar:filter.tagsIncluded")}
- name={"tagsIncluded" as const}
- tagsToOmit={TAGS_TO_OMIT}
- />
-
-
- label={t("calendar:filter.tagsExcluded")}
- name={"tagsExcluded" as const}
- tagsToOmit={TAGS_TO_OMIT}
- />
-
-
- label={t("calendar:filter.isSendou")}
- name={"isSendou" as const}
- />
-
-
- label={t("calendar:filter.isRanked")}
- name={"isRanked" as const}
- />
-
-
- label={t("calendar:filter.minTeamCount")}
- type="number"
- name={"minTeamCount" as const}
- />
-
-
- label={t("calendar:filter.orgsIncluded")}
- name={"orgsIncluded" as const}
- />
-
-
- label={t("calendar:filter.orgsExcluded")}
- name={"orgsExcluded" as const}
- />
-
-
- label={t("calendar:filter.authorIdsExcluded")}
- name={"authorIdsExcluded" as const}
- bottomText={t("calendar:filter.authorIdsExcludedBottom")}
- />
-
-
- onApply()}>
- {t("calendar:filter.apply")}
-
- {user ? (
-
- {t("calendar:filter.applyAndDefault")}
-
- ) : null}
-
-
-
+ : null}
+ >
+ {({ FormField }) => (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function ApplyAndPersistButton() {
+ const { t } = useTranslation(["calendar"]);
+ const { values, submitToServer, fetcherState } = useFormFieldContext();
+
+ return (
+ submitToServer(values as CalendarFilters)}
+ isDisabled={fetcherState !== "idle"}
+ >
+ {t("calendar:filter.applyAndDefault")}
+
);
}
diff --git a/app/features/calendar/components/TagsFormField.tsx b/app/features/calendar/components/TagsFormField.tsx
deleted file mode 100644
index 6045bf388..000000000
--- a/app/features/calendar/components/TagsFormField.tsx
+++ /dev/null
@@ -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({
- label,
- name,
- bottomText,
- tagsToOmit,
-}: {
- label: string;
- name: FieldPath;
- bottomText?: string;
- tagsToOmit?: Array;
-}) {
- const methods = useFormContext();
- const id = React.useId();
-
- const error = get(methods.formState.errors, name);
-
- return (
-
- {label}
- (
-
- )}
- />
- {error && (
- {error.message as string}
- )}
- {bottomText && !error ? (
- {bottomText}
- ) : null}
-
- );
-}
-
-const SelectableTags = React.forwardRef<
- HTMLDivElement,
- {
- selectedTags: Array;
- tagsToOmit?: Array;
- onSelectionChange: (selectedTags: Array) => void;
- }
->(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
- const { t } = useTranslation();
-
- const availableTags = tagsToOmit
- ? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
- : CALENDAR_EVENT.TAGS;
-
- return (
-
- onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
- }
- aria-label="Select tags"
- ref={ref}
- >
-
- {availableTags.map((tag) => {
- return (
-
- {t(`tag.name.${tag}`)}
-
- );
- })}
-
-
- );
-});
diff --git a/app/features/img-upload/ImageRepository.server.test.ts b/app/features/img-upload/ImageRepository.server.test.ts
index a98a7fefd..5ee9f37ee 100644
--- a/app/features/img-upload/ImageRepository.server.test.ts
+++ b/app/features/img-upload/ImageRepository.server.test.ts
@@ -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;
};
diff --git a/app/features/img-upload/actions/upload.server.ts b/app/features/img-upload/actions/upload.server.ts
index 6b970e564..878402af9 100644
--- a/app/features/img-upload/actions/upload.server.ts
+++ b/app/features/img-upload/actions/upload.server.ts
@@ -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);
diff --git a/app/features/leaderboards/queries/seasonPopularUsersWeapon.server.ts b/app/features/leaderboards/queries/seasonPopularUsersWeapon.server.ts
index 62a6c6f73..2aa25aa93 100644
--- a/app/features/leaderboards/queries/seasonPopularUsersWeapon.server.ts
+++ b/app/features/leaderboards/queries/seasonPopularUsersWeapon.server.ts
@@ -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"
`);
diff --git a/app/features/live-streams/LiveStreamRepository.server.ts b/app/features/live-streams/LiveStreamRepository.server.ts
new file mode 100644
index 000000000..7d70b8f13
--- /dev/null
+++ b/app/features/live-streams/LiveStreamRepository.server.ts
@@ -0,0 +1,14 @@
+import { db } from "~/db/sql";
+import type { TablesInsertable } from "~/db/tables";
+
+export function replaceAll(
+ streams: Omit[],
+) {
+ return db.transaction().execute(async (trx) => {
+ await trx.deleteFrom("LiveStream").execute();
+
+ if (streams.length > 0) {
+ await trx.insertInto("LiveStream").values(streams).execute();
+ }
+ });
+}
diff --git a/app/features/map-planner/components/Planner.tsx b/app/features/map-planner/components/Planner.tsx
index fc7ab0ccf..f60970e94 100644
--- a/app/features/map-planner/components/Planner.tsx
+++ b/app/features/map-planner/components/Planner.tsx
@@ -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) => {
diff --git a/app/features/mmr/core/Seasons.ts b/app/features/mmr/core/Seasons.ts
index c9900fa49..485e3f4f7 100644
--- a/app/features/mmr/core/Seasons.ts
+++ b/app/features/mmr/core/Seasons.ts
@@ -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,
diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts
index f6fa3e22b..462538523 100644
--- a/app/features/scrims/actions/scrims.new.server.ts
+++ b/app/features/scrims/actions/scrims.new.server.ts
@@ -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({
@@ -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;
+}
diff --git a/app/features/scrims/components/LutiDivsFormField.tsx b/app/features/scrims/components/LutiDivsFormField.tsx
deleted file mode 100644
index c10414ec0..000000000
--- a/app/features/scrims/components/LutiDivsFormField.tsx
+++ /dev/null
@@ -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 (
-
- (
-
- )}
- />
-
- {error && (
- {error.message as string}
- )}
-
- );
-}
-
-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) => {
- 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) => {
- const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
-
- onChange(
- newValue || value?.min
- ? { max: newValue, min: value?.min ?? null }
- : null,
- );
- };
-
- return (
-
-
- {t("scrims:forms.divs.maxDiv.title")}
-
- —
- {LUTI_DIVS.map((div) => (
-
- {div}
-
- ))}
-
-
-
-
- {t("scrims:forms.divs.minDiv.title")}
-
- —
- {LUTI_DIVS.map((div) => (
-
- {div}
-
- ))}
-
-
-
- );
-}
diff --git a/app/features/scrims/components/ScrimFiltersDialog.tsx b/app/features/scrims/components/ScrimFiltersDialog.tsx
index ad17bfc7b..9d044dec7 100644
--- a/app/features/scrims/components/ScrimFiltersDialog.tsx
+++ b/app/features/scrims/components/ScrimFiltersDialog.tsx
@@ -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;
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();
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 (
+ : null}
+ >
+ {({ FormField }) => (
+ <>
+
+
+
+ >
+ )}
+
+ );
+}
+
+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[0],
- },
- {
- method: "post",
- encType: "application/json",
- },
- ),
- ),
- [],
- );
-
return (
-
-
-
-
-
- label={t("scrims:filters.weekdayStart")}
- name={"weekdayTimes.start" as const}
- type="time"
- />
-
- label={t("scrims:filters.weekdayEnd")}
- name={"weekdayTimes.end" as const}
- type="time"
- />
-
-
-
-
- label={t("scrims:filters.weekendStart")}
- name={"weekendTimes.start" as const}
- type="time"
- />
-
- label={t("scrims:filters.weekendEnd")}
- name={"weekendTimes.end" as const}
- type="time"
- />
-
-
-
-
-
- onApply()}>
- {t("scrims:filters.apply")}
-
- {user ? (
-
- {t("scrims:filters.applyAndDefault")}
-
- ) : null}
-
-
-
+
+ {t("scrims:filters.applyAndDefault")}
+
);
}
diff --git a/app/features/scrims/components/ScrimRequestModal.tsx b/app/features/scrims/components/ScrimRequestModal.tsx
index 6012030e2..d6c9ff852 100644
--- a/app/features/scrims/components/ScrimRequestModal.tsx
+++ b/app/features/scrims/components/ScrimRequestModal.tsx
@@ -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 (
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,
}}
>
-
- {new Intl.ListFormat(i18n.language).format(
- post.users.map((u) => u.username),
- )}
-
- {post.text ? (
- {post.text}
- ) : null}
-
-
- {post.rangeEnd ? (
-
- name="at"
- label={t("scrims:requestModal.at.label")}
- bottomText={t("scrims:requestModal.at.explanation")}
- values={timeOptions}
- />
- ) : null}
-
- name="message"
- label={t("scrims:requestModal.message.label")}
- maxLength={SCRIM.REQUEST_MESSAGE_MAX_LENGTH}
- />
+ {({ FormField }) => (
+ <>
+
+ {new Intl.ListFormat(i18n.language).format(
+ post.users.map((u) => u.username),
+ )}
+
+ {post.text ? (
+ {post.text}
+ ) : null}
+
+
+ {(props: CustomFieldRenderProps) => (
+
+ )}
+
+ {post.rangeEnd ? (
+
+ ) : null}
+
+ >
+ )}
);
diff --git a/app/features/scrims/components/WithFormField.tsx b/app/features/scrims/components/WithFormField.tsx
index 8ae930a81..c34d3f4ae 100644
--- a/app/features/scrims/components/WithFormField.tsx
+++ b/app/features/scrims/components/WithFormField.tsx
@@ -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;
+
+interface WithFormFieldProps {
usersTeams: Array<{
id: number;
name: string;
members: Array;
}>;
+ 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();
+ const id = React.useId();
+ const { translatedError } = useTranslatedTexts({ error });
+
+ const fromValue = value as FromValue | null;
+
+ const handleSelectChange = (e: React.ChangeEvent) => {
+ 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 (
-
-
{t("scrims:forms.with.title")}
-
{
- const setTeam = (teamId: number) => {
- onChange({ teamId, mode: "TEAM" });
- };
-
- const error =
- (fieldState.error as any)?.users ?? fieldState.error?.root;
- return (
-
-
{
- 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) => (
-
- {team.name}
-
- ))}
- {t("scrims:forms.with.pick-up")}
-
- {value.mode === "PICKUP" ? (
-
-
- {value.users.map((userId, i) => (
-
- 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 ? (
-
- {error.message as string}
-
- ) : (
-
- {t("scrims:forms.with.explanation")}
-
- )}
-
- ) : null}
-
- );
- }}
- />
-
+
+
+ {usersTeams.map((team) => (
+
+ {team.name}
+
+ ))}
+ {t("scrims:forms.with.pick-up")}
+
+ {fromValue?.mode === "PICKUP" ? (
+
+
+ {fromValue.users.map((userId, i) => (
+ handleUserChange(selectedUser, i)}
+ isRequired={i < 3}
+ label={t("scrims:forms.with.user", { nth: i + 2 })}
+ />
+ ))}
+ {translatedError ? (
+
+ {translatedError}
+
+ ) : (
+
+ {t("scrims:forms.with.explanation")}
+
+ )}
+
+ ) : null}
+
);
}
diff --git a/app/features/scrims/routes/scrims.$id.tsx b/app/features/scrims/routes/scrims.$id.tsx
index 8243ebf17..cfe157b72 100644
--- a/app/features/scrims/routes/scrims.$id.tsx
+++ b/app/features/scrims/routes/scrims.$id.tsx
@@ -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;
-
function CancelScrimForm() {
- const { t } = useTranslation(["scrims"]);
-
return (
-
- name="reason"
- label={t("cancelModal.scrim.reasonLabel")}
- maxLength={SCRIM.CANCEL_REASON_MAX_LENGTH}
- bottomText={t("scrims:cancelModal.scrim.reasonExplanation")}
- />
+ {({ FormField }) => }
);
}
diff --git a/app/features/scrims/routes/scrims.new.module.css b/app/features/scrims/routes/scrims.new.module.css
new file mode 100644
index 000000000..32c62a3c9
--- /dev/null
+++ b/app/features/scrims/routes/scrims.new.module.css
@@ -0,0 +1,4 @@
+.datePickerFullWidth {
+ --input-width: 100%;
+ width: 100%;
+}
diff --git a/app/features/scrims/routes/scrims.new.test.ts b/app/features/scrims/routes/scrims.new.test.ts
index 755e8c48d..b00e59660 100644
--- a/app/features/scrims/routes/scrims.new.test.ts
+++ b/app/features/scrims/routes/scrims.new.test.ts
@@ -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({
+const newScrimAction = wrappedAction({
action,
isJsonSubmission: true,
});
@@ -24,7 +24,7 @@ const defaultNewScrimPostArgs: Parameters[0] = {
at: new Date(),
rangeEnd: null,
baseVisibility: "PUBLIC",
- divs: { min: null, max: null },
+ divs: [null, null],
from: {
mode: "PICKUP",
users: [1, 3, 4],
diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx
index 3e2ee5a6d..d09104a05 100644
--- a/app/features/scrims/routes/scrims.new.tsx
+++ b/app/features/scrims/routes/scrims.new.tsx
@@ -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;
+type FormFields = z.infer;
const DEFAULT_NOT_FOUND_VISIBILITY = {
at: null,
@@ -44,13 +41,12 @@ export default function NewScrimPage() {
return (
-
+ {({ FormField }) => (
+ <>
+
+ {(props: CustomFieldRenderProps) => (
+
+ )}
+
-
- label={t("scrims:forms.when.title")}
- name="at"
- bottomText={t("scrims:forms.when.explanation")}
- granularity="minute"
- />
-
- 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}`),
- })),
- ]}
- />
+
+
-
+
+ {(props: CustomFieldRenderProps) => (
+
+ )}
+
-
+
-
+
-
- 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") },
- ]}
- />
+
-
+
-
- label={t("scrims:forms.text.title")}
- name="postText"
- maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
- />
+
-
- label={t("scrims:forms.managedByAnyone.title")}
- name="managedByAnyone"
- bottomText={t("scrims:forms.managedByAnyone.explanation")}
- />
+
+ >
+ )}
);
@@ -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();
-
- const error = methods.formState.errors.baseVisibility;
+ const id = React.useId();
const noAssociations =
associations.virtual.length === 0 && associations.actual.length === 0;
return (
-
-
{t("scrims:forms.visibility.title")}
+
{noAssociations ? (
{t("scrims:forms.visibility.noneAvailable")}
@@ -153,15 +134,12 @@ function BaseVisibilityFormField({
) : (
onChange(e.target.value)}
/>
)}
-
- {error && (
- {error.message as string}
- )}
-
+
);
}
@@ -170,68 +148,112 @@ function NotFoundVisibilityFormField({
}: {
associations: ScrimsNewLoaderData["associations"];
}) {
- const { t } = useTranslation(["scrims"]);
- const baseVisibility = useWatch({
- name: "baseVisibility",
- });
- const date = useWatch({ name: "notFoundVisibility.at" }) ?? "";
- const methods = useFormContext();
+ 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) => {
+ setValue("notFoundVisibility", {
+ ...notFoundVisibility,
+ forAssociation: e.target.value,
+ });
+ };
+
+ const dateValue = notFoundVisibility.at
+ ? dateToDateValue(new Date(notFoundVisibility.at))
+ : null;
+
return (
-
- label={t("scrims:forms.notFoundVisibility.title")}
- name="notFoundVisibility.at"
- granularity="minute"
- />
- {date ? (
-
+
+
+
+ {notFoundVisibility.at ? (
+
{t("scrims:forms.visibility.title")}
) : null}
- {error ? (
- {error.message as string}
- ) : (
-
- {t("scrims:forms.notFoundVisibility.explanation")}
-
- )}
);
}
-const AssociationSelect = React.forwardRef<
- HTMLSelectElement,
- {
- associations: ScrimsNewLoaderData["associations"];
- } & React.SelectHTMLAttributes
->(({ associations, ...rest }, ref) => {
+function AssociationSelect({
+ associations,
+ id,
+ value,
+ onChange,
+}: {
+ associations: ScrimsNewLoaderData["associations"];
+ id: string;
+ value: string;
+ onChange: (e: React.ChangeEvent) => void;
+}) {
const { t } = useTranslation(["scrims"]);
return (
-
+
{t("scrims:forms.visibility.public")}
{associations.virtual.map((association) => (
@@ -245,40 +267,42 @@ const AssociationSelect = React.forwardRef<
))}
);
-});
+}
function TournamentSearchFormField() {
const { t } = useTranslation(["scrims"]);
- const methods = useFormContext();
- const maps = useWatch({ 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 (
-
- (
- onChange(tournament?.id)}
- />
- )}
+
+
+ setValue("mapsTournamentId", tournament?.id ?? null)
+ }
/>
-
- {error ? (
- {error.message as string}
- ) : null}
-
+
);
}
diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts
index 12c9024fd..abfc9b831 100644
--- a/app/features/scrims/scrims-schemas.ts
+++ b/app/features/scrims/scrims-schemas.ts
@@ -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,
});
}
diff --git a/app/features/sendouq-settings/QSettingsRepository.server.ts b/app/features/sendouq-settings/QSettingsRepository.server.ts
index 8a150964f..e84d401d9 100644
--- a/app/features/sendouq-settings/QSettingsRepository.server.ts
+++ b/app/features/sendouq-settings/QSettingsRepository.server.ts
@@ -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();
diff --git a/app/features/sendouq-settings/actions/q.settings.server.ts b/app/features/sendouq-settings/actions/q.settings.server.ts
index c389e7726..1af486ddc 100644
--- a/app/features/sendouq-settings/actions/q.settings.server.ts
+++ b/app/features/sendouq-settings/actions/q.settings.server.ts
@@ -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,
diff --git a/app/features/sendouq-settings/q-settings-schemas.server.ts b/app/features/sendouq-settings/q-settings-schemas.server.ts
index f6718f1e5..3bc58de53 100644
--- a/app/features/sendouq-settings/q-settings-schemas.server.ts
+++ b/app/features/sendouq-settings/q-settings-schemas.server.ts
@@ -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,
diff --git a/app/features/sendouq-settings/q-settings-schemas.ts b/app/features/sendouq-settings/q-settings-schemas.ts
new file mode 100644
index 000000000..d41ddca4a
--- /dev/null
+++ b/app/features/sendouq-settings/q-settings-schemas.ts
@@ -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,
+ }),
+});
diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx
index 5913ae51d..ed1c4314c 100644
--- a/app/features/sendouq-settings/routes/q.settings.tsx
+++ b/app/features/sendouq-settings/routes/q.settings.tsx
@@ -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();
return (
@@ -263,128 +263,34 @@ function VoiceChat() {
{t("q:settings.voiceChat.header")}
-
-
-
-
-
- {t("common:actions.save")}
-
-
-
+
+
+ {({ FormField }) => (
+ <>
+
+
+ >
+ )}
+
+
);
}
-function VoiceChatAbility() {
- const { t } = useTranslation(["q"]);
- const data = useLoaderData();
-
- 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 (
-
-
{t("q:settings.voiceChat.canVC.header")}
- {(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
- return (
-
-
-
- {label(option)}
-
-
- );
- })}
-
- );
-}
-
-function Languages() {
- const { t } = useTranslation(["q"]);
- const data = useLoaderData();
- const [value, setValue] = React.useState(data.settings.languages ?? []);
-
- return (
-
-
-
{t("q:settings.voiceChat.languages.header")}
-
{
- const newLanguages = [...value, e.target.value].sort((a, b) =>
- a.localeCompare(b),
- );
- setValue(newLanguages);
- }}
- >
-
- {t("q:settings.voiceChat.languages.placeholder")}
-
- {languagesUnified
- .filter((lang) => !value.includes(lang.code))
- .map((option) => {
- return (
-
- {option.name}
-
- );
- })}
-
-
- {value.map((code) => {
- const name = languagesUnified.find((l) => l.code === code)?.name;
-
- return (
-
- {name}{" "}
- }
- variant="minimal-destructive"
- onPress={() => {
- const newLanguages = value.filter(
- (codeInArr) => codeInArr !== code,
- );
- setValue(newLanguages);
- }}
- />
-
- );
- })}
-
-
- );
-}
-
function WeaponPool() {
- const { t } = useTranslation(["common", "q"]);
+ const { t } = useTranslation(["q"]);
const data = useLoaderData();
- 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 (
@@ -393,98 +299,16 @@ function WeaponPool() {
{t("q:settings.weaponPool.header")}
-
-
-
- {weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
- {
- setWeapons([
- ...weapons,
- {
- weaponSplId,
- isFavorite: 0,
- },
- ]);
- }}
- // empty on selection
- key={latestWeapon ?? "empty"}
- disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
- />
- ) : (
-
- {t("q:settings.weaponPool.full")}
-
- )}
-
-
- {weapons.map((weapon) => {
- return (
-
-
-
-
-
-
- }
- variant="minimal"
- aria-label="Favorite weapon"
- onPress={() =>
- setWeapons(
- weapons.map((w) =>
- w.weaponSplId === weapon.weaponSplId
- ? {
- ...weapon,
- isFavorite: weapon.isFavorite === 1 ? 0 : 1,
- }
- : w,
- ),
- )
- }
- />
- }
- variant="minimal-destructive"
- aria-label="Delete weapon"
- onPress={() =>
- setWeapons(
- weapons.filter(
- (w) => w.weaponSplId !== weapon.weaponSplId,
- ),
- )
- }
- data-testid={`delete-weapon-${weapon.weaponSplId}`}
- size="small"
- />
-
-
- );
- })}
-
-
-
- {t("common:actions.save")}
-
-
-
+
+
+ {({ FormField }) => }
+
+
);
}
@@ -682,40 +506,25 @@ function TrustedUsers() {
function Misc() {
const data = useLoaderData();
- const [checked, setChecked] = React.useState(Boolean(data.settings.noScreen));
- const { t } = useTranslation(["common", "q", "weapons"]);
- const fetcher = useFetcher();
+ const { t } = useTranslation(["q"]);
return (
{t("q:settings.misc.header")}
-
-
-
-
- {t("q:settings.avoid.label", {
- special: t("weapons:SPECIAL_19"),
- })}
-
-
-
-
- {t("common:actions.save")}
-
-
-
+
+
+ {({ FormField }) => }
+
+
);
}
diff --git a/app/features/sendouq/core/SendouQ.server.test.ts b/app/features/sendouq/core/SendouQ.server.test.ts
index 15bed5a6d..d575c0d22 100644
--- a/app/features/sendouq/core/SendouQ.server.test.ts
+++ b/app/features/sendouq/core/SendouQ.server.test.ts
@@ -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 () => {
diff --git a/app/features/session-id/session-id-context.server.ts b/app/features/session-id/session-id-context.server.ts
new file mode 100644
index 000000000..6a98f5462
--- /dev/null
+++ b/app/features/session-id/session-id-context.server.ts
@@ -0,0 +1,18 @@
+import { AsyncLocalStorage } from "node:async_hooks";
+
+interface SessionIdContext {
+ sessionId: string | undefined;
+}
+
+export const sessionIdAsyncLocalStorage =
+ new AsyncLocalStorage();
+
+function getSessionId(): string | undefined {
+ return sessionIdAsyncLocalStorage.getStore()?.sessionId;
+}
+
+declare global {
+ var __getServerSessionId: (() => string | undefined) | undefined;
+}
+
+globalThis.__getServerSessionId = getSessionId;
diff --git a/app/features/session-id/session-id-middleware.server.ts b/app/features/session-id/session-id-middleware.server.ts
new file mode 100644
index 000000000..b60e2b705
--- /dev/null
+++ b/app/features/session-id/session-id-middleware.server.ts
@@ -0,0 +1,17 @@
+import { sessionIdAsyncLocalStorage } from "./session-id-context.server";
+
+type MiddlewareArgs = {
+ request: Request;
+ context: unknown;
+};
+
+type MiddlewareFn = (
+ args: MiddlewareArgs,
+ next: () => Promise,
+) => Promise;
+
+export const sessionIdMiddleware: MiddlewareFn = async ({ request }, next) => {
+ const sessionId = request.headers.get("Sendou-Session-Id") ?? undefined;
+
+ return sessionIdAsyncLocalStorage.run({ sessionId }, () => next());
+};
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts
index d8d597bdb..b60d45116 100644
--- a/app/features/settings/actions/settings.server.ts
+++ b/app/features/settings/actions/settings.server.ts
@@ -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");
};
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index c66078623..b15736d10 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -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")}
- {user ? : null}
+ {user ? (
+
+ {({ FormField }) => }
+
+ ) : null}
{t("common:settings.theme")}
@@ -64,36 +81,35 @@ export default function SettingsPage() {
-
-
-
+
+ {({ FormField }) => }
+
+
+ {({ FormField }) => }
+
+
+ {({ FormField }) => }
+