mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-12 14:16:04 -05:00
Merge branch 'main' into css-rework-sidenav
This commit is contained in:
@@ -6,5 +6,8 @@
|
||||
"PreCompact": [
|
||||
{ "hooks": [{ "type": "command", "command": "beans prime" }] }
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"code-review@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
|
||||
20
.github/workflows/e2e-tests.yml
vendored
20
.github/workflows/e2e-tests.yml
vendored
@@ -22,6 +22,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Start MinIO
|
||||
run: docker compose up -d minio
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://127.0.0.1:9000/minio/health/live; then
|
||||
echo "MinIO is ready"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for MinIO... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
echo "MinIO failed to start"
|
||||
exit 1
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
@@ -36,6 +52,10 @@ jobs:
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
|
||||
- name: Stop MinIO
|
||||
if: always()
|
||||
run: docker compose down
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "react-router";
|
||||
import { useLocation } from "react-use";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { getSessionId } from "~/utils/session-id";
|
||||
import {
|
||||
ERROR_GIRL_IMAGE_PATH,
|
||||
LOG_IN_URL,
|
||||
@@ -52,10 +53,11 @@ export function Catcher() {
|
||||
}
|
||||
|
||||
if (!isRouteErrorResponse(error)) {
|
||||
const sessionId = getSessionId();
|
||||
const errorText = (() => {
|
||||
if (!(error instanceof Error)) return;
|
||||
|
||||
return `Time: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`;
|
||||
return `Session ID: ${sessionId}\nTime: ${new Date().toISOString()}\nURL: ${location.href}\nUser ID: ${user?.id ?? "Not logged in"}\n${error.stack ?? error.message}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
@@ -124,12 +126,15 @@ export function Catcher() {
|
||||
<h2>Error {error.status}</h2>
|
||||
<GetHelp />
|
||||
<div className="text-sm text-lighter font-semi-bold">
|
||||
Please include the message below if any and an explanation on what
|
||||
you were doing:
|
||||
Please include the session ID and message below if any and an
|
||||
explanation on what you were doing:
|
||||
</div>
|
||||
{error.data ? (
|
||||
<pre>{JSON.stringify(JSON.parse(error.data), null, 2)}</pre>
|
||||
) : null}
|
||||
<pre>
|
||||
Session ID: {getSessionId()}
|
||||
{error.data
|
||||
? `\n${JSON.stringify(JSON.parse(error.data), null, 2)}`
|
||||
: null}
|
||||
</pre>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type * as React from "react";
|
||||
|
||||
export function Draggable({
|
||||
id,
|
||||
disabled,
|
||||
liClassName,
|
||||
children,
|
||||
testId,
|
||||
}: {
|
||||
id: number;
|
||||
disabled: boolean;
|
||||
liClassName: string;
|
||||
children: React.ReactNode;
|
||||
testId?: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
className={liClassName}
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
data-testid={testId}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -11,3 +11,7 @@
|
||||
font-size: var(--fonts-xs);
|
||||
margin-block-start: var(--label-margin);
|
||||
}
|
||||
|
||||
.noMargin {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,21 @@ export function FormMessage({
|
||||
children,
|
||||
type,
|
||||
className,
|
||||
spaced = true,
|
||||
id,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
type: "error" | "info";
|
||||
className?: string;
|
||||
spaced?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={clsx(
|
||||
{ [styles.info]: type === "info", [styles.error]: type === "error" },
|
||||
{ [styles.noMargin]: !spaced },
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.selectWidthWider {
|
||||
--select-width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
|
||||
@@ -46,6 +46,8 @@ interface WeaponSelectProps<
|
||||
isRequired?: boolean;
|
||||
/** If set, selection of weapons that user sees when search input is empty allowing for quick select for e.g. previous selections */
|
||||
quickSelectWeaponsIds?: Array<MainWeaponId>;
|
||||
isDisabled?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function WeaponSelect<
|
||||
@@ -62,11 +64,20 @@ export function WeaponSelect<
|
||||
testId = "weapon-select",
|
||||
isRequired,
|
||||
quickSelectWeaponsIds,
|
||||
isDisabled,
|
||||
placeholder,
|
||||
}: WeaponSelectProps<Clearable, IncludeSubSpecial>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const selectedWeaponId: MainWeaponId | null =
|
||||
typeof value === "number"
|
||||
? (value as MainWeaponId)
|
||||
: value && typeof value === "object" && value.type === "MAIN"
|
||||
? (value.id as MainWeaponId)
|
||||
: null;
|
||||
const { items, filterValue, setFilterValue } = useWeaponItems({
|
||||
includeSubSpecial,
|
||||
quickSelectWeaponsIds,
|
||||
selectedWeaponId,
|
||||
});
|
||||
const filter = useWeaponFilter();
|
||||
|
||||
@@ -97,9 +108,10 @@ export function WeaponSelect<
|
||||
aria-label={
|
||||
!label ? t("common:forms.weaponSearch.placeholder") : undefined
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
items={items}
|
||||
label={label}
|
||||
placeholder={t("common:forms.weaponSearch.placeholder")}
|
||||
placeholder={placeholder ?? t("common:forms.weaponSearch.placeholder")}
|
||||
search={{
|
||||
placeholder: t("common:forms.weaponSearch.search.placeholder"),
|
||||
}}
|
||||
@@ -215,9 +227,11 @@ function useWeaponFilter() {
|
||||
function useWeaponItems({
|
||||
includeSubSpecial,
|
||||
quickSelectWeaponsIds,
|
||||
selectedWeaponId,
|
||||
}: {
|
||||
includeSubSpecial: boolean | undefined;
|
||||
quickSelectWeaponsIds?: Array<MainWeaponId>;
|
||||
selectedWeaponId?: MainWeaponId | null;
|
||||
}) {
|
||||
const items = useAllWeaponCategories(includeSubSpecial);
|
||||
const [filterValue, setFilterValue] = React.useState("");
|
||||
@@ -227,6 +241,11 @@ function useWeaponItems({
|
||||
filterValue === "" && quickSelectWeaponsIds?.length;
|
||||
|
||||
if (showQuickSelectWeapons) {
|
||||
const weaponIdsToInclude = new Set(quickSelectWeaponsIds);
|
||||
if (typeof selectedWeaponId === "number") {
|
||||
weaponIdsToInclude.add(selectedWeaponId);
|
||||
}
|
||||
|
||||
const quickSelectCategory = {
|
||||
idx: 0,
|
||||
key: "quick-select" as const,
|
||||
@@ -238,7 +257,7 @@ function useWeaponItems({
|
||||
.filter((val) => val !== null),
|
||||
)
|
||||
.filter((item) =>
|
||||
quickSelectWeaponsIds.includes(item.weapon.id as MainWeaponId),
|
||||
weaponIdsToInclude.has(item.weapon.id as MainWeaponId),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aIdx = quickSelectWeaponsIds.indexOf(
|
||||
|
||||
@@ -4,18 +4,20 @@ import { SendouFieldMessage } from "~/components/elements/FieldMessage";
|
||||
export function SendouBottomTexts({
|
||||
bottomText,
|
||||
errorText,
|
||||
errorId,
|
||||
}: {
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
errorId?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{errorText ? (
|
||||
<SendouFieldError>{errorText}</SendouFieldError>
|
||||
<SendouFieldError id={errorId}>{errorText}</SendouFieldError>
|
||||
) : (
|
||||
<SendouFieldError />
|
||||
)}
|
||||
{bottomText && !errorText ? (
|
||||
{bottomText ? (
|
||||
<SendouFieldMessage>{bottomText}</SendouFieldMessage>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "react-aria-components";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouCalendar } from "~/components/elements/Calendar";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import styles from "./DatePicker.module.css";
|
||||
import { SendouLabel } from "./Label";
|
||||
|
||||
@@ -20,15 +21,33 @@ interface SendouDatePickerProps<T extends DateValue>
|
||||
label: string;
|
||||
bottomText?: string;
|
||||
errorText?: string;
|
||||
errorId?: string;
|
||||
}
|
||||
|
||||
export function SendouDatePicker<T extends DateValue>({
|
||||
label,
|
||||
errorText,
|
||||
errorId,
|
||||
bottomText,
|
||||
isRequired,
|
||||
...rest
|
||||
}: SendouDatePickerProps<T>) {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div>
|
||||
<SendouLabel required={isRequired}>{label}</SendouLabel>
|
||||
<input type="text" disabled />
|
||||
<SendouBottomTexts
|
||||
bottomText={bottomText}
|
||||
errorText={errorText}
|
||||
errorId={errorId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactAriaDatePicker
|
||||
{...rest}
|
||||
@@ -46,7 +65,11 @@ export function SendouDatePicker<T extends DateValue>({
|
||||
<Calendar className={styles.icon} />
|
||||
</Button>
|
||||
</Group>
|
||||
<SendouBottomTexts bottomText={bottomText} errorText={errorText} />
|
||||
<SendouBottomTexts
|
||||
bottomText={bottomText}
|
||||
errorText={errorText}
|
||||
errorId={errorId}
|
||||
/>
|
||||
<Popover>
|
||||
<Dialog>
|
||||
<SendouCalendar />
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { FieldError as ReactAriaFieldError } from "react-aria-components";
|
||||
|
||||
export function SendouFieldError({ children }: { children?: React.ReactNode }) {
|
||||
export function SendouFieldError({
|
||||
children,
|
||||
id,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<ReactAriaFieldError className="error-message">
|
||||
<ReactAriaFieldError className="error-message" id={id}>
|
||||
{children}
|
||||
</ReactAriaFieldError>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Plus } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
|
||||
export function AddFieldButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
icon={<Plus />}
|
||||
aria-label="Add form field"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onPress={onClick}
|
||||
className="self-start"
|
||||
data-testid="add-field-button"
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { dateToDateValue, dayMonthYearToDateValue } from "../../utils/dates";
|
||||
import type { DayMonthYear } from "../../utils/zod";
|
||||
import { SendouDatePicker } from "../elements/DatePicker";
|
||||
|
||||
export function DateFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
required,
|
||||
granularity = "day",
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
required?: boolean;
|
||||
granularity?: "day" | "minute";
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={methods.control}
|
||||
render={({
|
||||
field: { name, value, onChange, onBlur /*, ref*/ }, // TODO: figure out where ref goes (to focus on error) and put it there
|
||||
fieldState: { invalid, error },
|
||||
}) => {
|
||||
const getValue = () => {
|
||||
const originalValue = value as DayMonthYear | Date | null;
|
||||
|
||||
if (!originalValue) return null;
|
||||
|
||||
if (originalValue instanceof Date) {
|
||||
return dateToDateValue(originalValue);
|
||||
}
|
||||
|
||||
return dayMonthYearToDateValue(originalValue as DayMonthYear);
|
||||
};
|
||||
|
||||
return (
|
||||
<SendouDatePicker
|
||||
label={label}
|
||||
granularity={granularity}
|
||||
isRequired={required}
|
||||
errorText={error?.message as string | undefined}
|
||||
value={getValue()}
|
||||
isInvalid={invalid}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
if (granularity === "minute") {
|
||||
onChange(
|
||||
new Date(
|
||||
value.year,
|
||||
value.month - 1,
|
||||
value.day,
|
||||
(value as CalendarDateTime).hour,
|
||||
(value as CalendarDateTime).minute,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
onChange({
|
||||
day: value.day,
|
||||
month: value.month - 1,
|
||||
year: value.year,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
onChange(null);
|
||||
}
|
||||
}}
|
||||
bottomText={bottomText}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { RemoveFieldButton } from "./RemoveFieldButton";
|
||||
|
||||
export function FormFieldset({
|
||||
title,
|
||||
children,
|
||||
onRemove,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<fieldset className="w-min">
|
||||
<legend>{title}</legend>
|
||||
<div className="stack sm">
|
||||
{children}
|
||||
|
||||
<div className="mt-4 stack items-center">
|
||||
<RemoveFieldButton onClick={onRemove} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
|
||||
export function InputFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
placeholder,
|
||||
required,
|
||||
type,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
type?: React.HTMLInputTypeAttribute;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} required={required}>
|
||||
{label}
|
||||
</Label>
|
||||
<input
|
||||
id={id}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
{...methods.register(name)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
|
||||
interface InputGroupFormFieldProps<T extends FieldValues> {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
direction?: "horizontal" | "vertical";
|
||||
type: "checkbox" | "radio";
|
||||
values: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function InputGroupFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
values,
|
||||
type,
|
||||
direction = "vertical",
|
||||
}: InputGroupFormFieldProps<T>) {
|
||||
const methods = useFormContext();
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={name}
|
||||
control={methods.control}
|
||||
render={({
|
||||
field: { name, value, onChange, ref },
|
||||
fieldState: { error },
|
||||
}) => {
|
||||
const handleCheckboxChange =
|
||||
(name: string) => (newChecked: boolean) => {
|
||||
const newValue = newChecked
|
||||
? [...(value || []), name]
|
||||
: value?.filter((v: string) => v !== name);
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleRadioChange = (name: string) => () => {
|
||||
onChange(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fieldset
|
||||
className={clsx("stack sm", {
|
||||
"horizontal md": direction === "horizontal",
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
<legend>{label}</legend>
|
||||
|
||||
{values.map((checkbox) => {
|
||||
const isChecked = value?.includes(checkbox.value);
|
||||
|
||||
return (
|
||||
<GroupInput
|
||||
key={checkbox.value}
|
||||
type={type}
|
||||
name={name}
|
||||
checked={isChecked}
|
||||
onChange={
|
||||
type === "checkbox"
|
||||
? handleCheckboxChange(checkbox.value)
|
||||
: handleRadioChange(checkbox.value)
|
||||
}
|
||||
>
|
||||
{checkbox.label}
|
||||
</GroupInput>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupInput({
|
||||
children,
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
type,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
name: string;
|
||||
checked: boolean;
|
||||
onChange: (newChecked: boolean) => void;
|
||||
type: "checkbox" | "radio";
|
||||
}) {
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input
|
||||
type={type}
|
||||
id={id}
|
||||
name={name}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor={id} className="mb-0">
|
||||
{children}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Trash } from "lucide-react";
|
||||
import { SendouButton } from "../elements/Button";
|
||||
|
||||
export function RemoveFieldButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<SendouButton
|
||||
icon={<Trash />}
|
||||
aria-label="Remove form field"
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
|
||||
export function SelectFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
values,
|
||||
bottomText,
|
||||
required,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
values: Array<{ value: string | number; label: string }>;
|
||||
bottomText?: string;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id} required={required}>
|
||||
{label}
|
||||
</Label>
|
||||
<select {...methods.register(name)} id={id}>
|
||||
{values.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import * as React from "react";
|
||||
import { type DefaultValues, FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { ActionError } from "~/utils/remix.server";
|
||||
import { LinkButton } from "../elements/Button";
|
||||
import { SubmitButton } from "../SubmitButton";
|
||||
|
||||
export function SendouForm<T extends z.ZodTypeAny>({
|
||||
schema,
|
||||
defaultValues,
|
||||
heading,
|
||||
children,
|
||||
cancelLink,
|
||||
submitButtonTestId,
|
||||
}: {
|
||||
schema: T;
|
||||
defaultValues?: DefaultValues<z.infer<T>>;
|
||||
heading?: string;
|
||||
children: React.ReactNode;
|
||||
cancelLink?: string;
|
||||
submitButtonTestId?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const fetcher = useFetcher<any>();
|
||||
const methods = useForm({
|
||||
resolver: standardSchemaResolver(schema as any),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
if (methods.formState.isSubmitted && methods.formState.errors) {
|
||||
logger.error(methods.formState.errors);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!fetcher.data?.isError) return;
|
||||
|
||||
const error = fetcher.data as ActionError;
|
||||
|
||||
methods.setError(error.field as any, {
|
||||
message: error.msg,
|
||||
});
|
||||
}, [fetcher.data, methods.setError]);
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
methods.handleSubmit((values) =>
|
||||
fetcher.submit(values as Parameters<typeof fetcher.submit>[0], {
|
||||
method: "post",
|
||||
encType: "application/json",
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
|
||||
{heading ? <h1 className="text-lg">{heading}</h1> : null}
|
||||
{children}
|
||||
<div className="stack horizontal lg justify-between mt-6 w-full">
|
||||
<SubmitButton state={fetcher.state} testId={submitButtonTestId}>
|
||||
{t("common:actions.submit")}
|
||||
</SubmitButton>
|
||||
{cancelLink ? (
|
||||
<LinkButton
|
||||
variant="minimal-destructive"
|
||||
to={cancelLink}
|
||||
size="small"
|
||||
>
|
||||
{t("common:actions.cancel")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
|
||||
export function TextAreaFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
maxLength,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
maxLength: number;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const value = useWatch({ name }) ?? "";
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
valueLimits={{ current: value.length, max: maxLength }}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<textarea id={id} {...methods.register(name)} />
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
useFieldArray,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { AddFieldButton } from "./AddFieldButton";
|
||||
import { RemoveFieldButton } from "./RemoveFieldButton";
|
||||
|
||||
export function TextArrayFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
/** If "plain", value in the text array is a plain string. If "object" then an object containing the text under "value" key */
|
||||
format = "plain",
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
format?: "plain" | "object";
|
||||
}) {
|
||||
const {
|
||||
register,
|
||||
formState: { errors },
|
||||
clearErrors,
|
||||
} = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name,
|
||||
});
|
||||
|
||||
const rootError = errors[name]?.root;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{label}</Label>
|
||||
<div className="stack md">
|
||||
{fields.map((field, index) => {
|
||||
// @ts-expect-error
|
||||
const error = errors[name]?.[index]?.value;
|
||||
|
||||
return (
|
||||
<div key={field.id}>
|
||||
<div className="stack horizontal md">
|
||||
<input
|
||||
{...register(
|
||||
format === "plain"
|
||||
? `${name}.${index}`
|
||||
: `${name}.${index}.value`,
|
||||
)}
|
||||
/>
|
||||
<RemoveFieldButton
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
clearErrors(`${name}.root`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<FormMessage type="error">
|
||||
{error.message as string}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<AddFieldButton
|
||||
// @ts-expect-error
|
||||
onClick={() => append(format === "plain" ? "" : { value: "" })}
|
||||
/>
|
||||
{rootError && (
|
||||
<FormMessage type="error">{rootError.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !rootError ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SendouSwitch } from "../elements/Switch";
|
||||
|
||||
export function ToggleFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SendouSwitch
|
||||
id={id}
|
||||
isSelected={value ?? false}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { UserSearch } from "../elements/UserSearch";
|
||||
|
||||
export function UserSearchFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ field: { onChange, onBlur, value, ref } }) => (
|
||||
<UserSearch
|
||||
onChange={(newUser) => onChange(newUser?.id)}
|
||||
initialUserId={value}
|
||||
onBlur={onBlur}
|
||||
ref={ref}
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { add, sub } from "date-fns";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as R from "remeda";
|
||||
import { db, sql } from "~/db/sql";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
@@ -176,10 +177,12 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||
nzapUser,
|
||||
users,
|
||||
fixAdminId,
|
||||
makeArtists,
|
||||
adminUserWeaponPool,
|
||||
userProfiles,
|
||||
userMapModePreferences,
|
||||
userQWeaponPool,
|
||||
seedingSkills,
|
||||
lastMonthsVoting,
|
||||
syncPlusTiers,
|
||||
lastMonthSuggestions,
|
||||
@@ -237,6 +240,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||
associations,
|
||||
notifications,
|
||||
friendships,
|
||||
liveStreams,
|
||||
];
|
||||
|
||||
export async function seed(variation?: SeedVariation | null) {
|
||||
@@ -264,6 +268,8 @@ function wipeDB() {
|
||||
"GroupMatchMap",
|
||||
"GroupMatch",
|
||||
"Group",
|
||||
"TaggedArt",
|
||||
"ArtTag",
|
||||
"ArtUserMetadata",
|
||||
"Art",
|
||||
"UnvalidatedUserSubmittedImage",
|
||||
@@ -301,6 +307,8 @@ function wipeDB() {
|
||||
"TournamentBadgeOwner",
|
||||
"BadgeManager",
|
||||
"TournamentOrganization",
|
||||
"SeedingSkill",
|
||||
"LiveStream",
|
||||
];
|
||||
|
||||
for (const table of tablesToDelete) {
|
||||
@@ -349,6 +357,14 @@ function makeAdminTournamentOrganizer() {
|
||||
.run();
|
||||
}
|
||||
|
||||
function makeArtists() {
|
||||
sql
|
||||
.prepare(
|
||||
`update "User" set "isArtist" = 1 where id in (${ADMIN_ID}, ${NZAP_TEST_ID})`,
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
function adminUserWeaponPool() {
|
||||
for (const [i, weaponSplId] of [200, 1100, 2000, 4000].entries()) {
|
||||
sql
|
||||
@@ -548,6 +564,38 @@ async function userQWeaponPool() {
|
||||
}
|
||||
}
|
||||
|
||||
function seedingSkills() {
|
||||
const users = sql.prepare('SELECT id FROM "User" LIMIT 500').all() as {
|
||||
id: number;
|
||||
}[];
|
||||
|
||||
for (const { id: userId } of users) {
|
||||
if (faker.number.float() < 0.7) {
|
||||
const mu = faker.number.float({ min: 22, max: 45 });
|
||||
const sigma = faker.number.float({ min: 4, max: 8 });
|
||||
const ordinal = mu - 3 * sigma;
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'RANKED', ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, mu, sigma, ordinal);
|
||||
}
|
||||
|
||||
if (faker.number.float() < 0.5) {
|
||||
const mu = faker.number.float({ min: 22, max: 42 });
|
||||
const sigma = faker.number.float({ min: 4, max: 8 });
|
||||
const ordinal = mu - 3 * sigma;
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`INSERT INTO "SeedingSkill" ("userId", "type", "mu", "sigma", "ordinal") VALUES (?, 'UNRANKED', ?, ?, ?)`,
|
||||
)
|
||||
.run(userId, mu, sigma, ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeUser(usedNames: Set<string>) {
|
||||
return () => ({
|
||||
discordAvatar: null,
|
||||
@@ -1352,13 +1400,15 @@ function calendarEventWithToToolsTeams(
|
||||
"name",
|
||||
"createdAt",
|
||||
"tournamentId",
|
||||
"inviteCode"
|
||||
"inviteCode",
|
||||
"seed"
|
||||
) values (
|
||||
$id,
|
||||
$name,
|
||||
$createdAt,
|
||||
$tournamentId,
|
||||
$inviteCode
|
||||
$inviteCode,
|
||||
$seed
|
||||
)
|
||||
`,
|
||||
)
|
||||
@@ -1368,6 +1418,7 @@ function calendarEventWithToToolsTeams(
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
tournamentId,
|
||||
inviteCode: shortNanoid(),
|
||||
seed: id,
|
||||
});
|
||||
|
||||
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
|
||||
@@ -2755,3 +2806,70 @@ async function friendships() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function liveStreams() {
|
||||
const userIds = userIdsInAscendingOrderById();
|
||||
|
||||
// Add deterministic streams for E2E testing
|
||||
// Users 6 and 7 are in ITZ tournament team 102
|
||||
const deterministicStreams = [
|
||||
{ userId: 6, viewerCount: 150, twitch: "test_player_stream_1" },
|
||||
{ userId: 7, viewerCount: 75, twitch: "test_player_stream_2" },
|
||||
// Cast-only stream (user 100 is not in ITZ tournament teams)
|
||||
{ userId: 100, viewerCount: 500, twitch: "test_cast_stream" },
|
||||
];
|
||||
|
||||
for (const stream of deterministicStreams) {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
|
||||
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
userId: stream.userId,
|
||||
viewerCount: stream.viewerCount,
|
||||
thumbnailUrl: "https://picsum.photos/320/180",
|
||||
twitch: stream.twitch,
|
||||
});
|
||||
}
|
||||
|
||||
const streamingUserIds = [
|
||||
...userIds.slice(3, 20),
|
||||
...userIds.slice(40, 50),
|
||||
...userIds.slice(100, 110),
|
||||
].filter((id) => !deterministicStreams.some((s) => s.userId === id));
|
||||
|
||||
const shuffledStreamers = faker.helpers.shuffle(streamingUserIds);
|
||||
const selectedStreamers = shuffledStreamers.slice(0, 17);
|
||||
|
||||
for (const userId of selectedStreamers) {
|
||||
const viewerCount = faker.helpers.weightedArrayElement([
|
||||
{ value: faker.number.int({ min: 5, max: 30 }), weight: 5 },
|
||||
{ value: faker.number.int({ min: 31, max: 100 }), weight: 3 },
|
||||
{ value: faker.number.int({ min: 101, max: 500 }), weight: 2 },
|
||||
{ value: faker.number.int({ min: 501, max: 2000 }), weight: 1 },
|
||||
]);
|
||||
|
||||
const thumbnailUrl = faker.image.urlPicsumPhotos({
|
||||
width: 320,
|
||||
height: 180,
|
||||
});
|
||||
|
||||
const twitch = `fake_${nanoid()}`.toLowerCase();
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "LiveStream" ("userId", "viewerCount", "thumbnailUrl", "twitch")
|
||||
values ($userId, $viewerCount, $thumbnailUrl, $twitch)
|
||||
`,
|
||||
)
|
||||
.run({
|
||||
userId,
|
||||
viewerCount,
|
||||
thumbnailUrl,
|
||||
twitch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -514,6 +514,16 @@ export interface Tournament {
|
||||
parentTournamentId: number | null;
|
||||
/** Is the tournament finalized meaning all the matches are played and TO has locked it making it read-only */
|
||||
isFinalized: Generated<DBBoolean>;
|
||||
/** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */
|
||||
seedingSnapshot: JSONColumnTypeNullable<SeedingSnapshot>;
|
||||
}
|
||||
|
||||
export interface SeedingSnapshot {
|
||||
savedAt: number;
|
||||
teams: Array<{
|
||||
teamId: number;
|
||||
members: Array<{ userId: number; username: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PreparedMaps {
|
||||
@@ -989,6 +999,14 @@ export interface ApiToken {
|
||||
createdAt: GeneratedAlways<number>;
|
||||
}
|
||||
|
||||
export interface LiveStream {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number | null;
|
||||
viewerCount: number;
|
||||
thumbnailUrl: string;
|
||||
twitch: string | null;
|
||||
}
|
||||
|
||||
export interface BanLog {
|
||||
id: GeneratedAlways<number>;
|
||||
userId: number;
|
||||
@@ -1163,6 +1181,7 @@ export interface DB {
|
||||
AllTeamMember: TeamMember;
|
||||
ApiToken: ApiToken;
|
||||
Art: Art;
|
||||
LiveStream: LiveStream;
|
||||
ArtTag: ArtTag;
|
||||
ArtUserMetadata: ArtUserMetadata;
|
||||
TaggedArt: TaggedArt;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -9,7 +9,12 @@ import { type EntryContext, ServerRouter } from "react-router";
|
||||
import { config } from "~/modules/i18n/config"; // your i18n configuration file
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { resources } from "./modules/i18n/resources.server";
|
||||
import { daily, everyHourAt00, everyHourAt30 } from "./routines/list.server";
|
||||
import {
|
||||
daily,
|
||||
everyHourAt00,
|
||||
everyHourAt30,
|
||||
everyTwoMinutes,
|
||||
} from "./routines/list.server";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
// Reject/cancel all pending promises after 5 seconds
|
||||
@@ -103,8 +108,19 @@ if (!global.appStartSignal && process.env.NODE_ENV === "production") {
|
||||
await routine.run();
|
||||
}
|
||||
});
|
||||
|
||||
cron.schedule("*/2 * * * *", async () => {
|
||||
for (const routine of everyTwoMinutes) {
|
||||
await routine.run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.on("unhandledRejection", (reason: string, p: Promise<any>) => {
|
||||
logger.error("Unhandled Rejection at:", p, "reason:", reason);
|
||||
});
|
||||
|
||||
// wrapper so we get request id shown in the server logs
|
||||
export function handleError(error: unknown) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
45
app/features/api-public/api-cors-middleware.server.ts
Normal file
45
app/features/api-public/api-cors-middleware.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
type MiddlewareArgs = {
|
||||
request: Request;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type MiddlewareFn = (
|
||||
args: MiddlewareArgs,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
|
||||
export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
|
||||
const url = new URL(request.url);
|
||||
const isApiRoute = url.pathname.startsWith("/api/");
|
||||
|
||||
if (!isApiRoute) {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await next();
|
||||
const newHeaders = new Headers(response.headers);
|
||||
|
||||
for (const [key, value] of Object.entries(CORS_HEADERS)) {
|
||||
newHeaders.set(key, value);
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: newHeaders,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface GetSendouqMatchResponse {
|
||||
}
|
||||
|
||||
type SendouqMatchTeam = {
|
||||
id: number;
|
||||
score: number;
|
||||
players: Array<SendouqMatchPlayer>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
|
||||
import { ASSOCIATION } from "~/features/associations/associations-constants";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
@@ -137,6 +139,22 @@ export function insert({ userId, ...associationArgs }: InsertArgs) {
|
||||
.insertInto("AssociationMember")
|
||||
.values({ userId, associationId: association.id, role: "ADMIN" })
|
||||
.execute();
|
||||
|
||||
const { count, patronTier } = await trx
|
||||
.selectFrom("AssociationMember")
|
||||
.innerJoin("User", "User.id", "AssociationMember.userId")
|
||||
.select((eb) => [eb.fn.countAll<number>().as("count"), "User.patronTier"])
|
||||
.where("AssociationMember.userId", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const maxCount =
|
||||
(patronTier ?? 0) >= 2
|
||||
? ASSOCIATION.MAX_COUNT_SUPPORTER
|
||||
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
|
||||
|
||||
if (count > maxCount) {
|
||||
throw new LimitReachedError("Max amount of associations reached");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
import { ASSOCIATION } from "~/features/associations/associations-constants";
|
||||
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { actionError, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import { associationsPage } from "~/utils/urls";
|
||||
import * as AssociationRepository from "../AssociationRepository.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: createNewAssociationSchema,
|
||||
});
|
||||
|
||||
const associationCount = (
|
||||
await AssociationRepository.findByMemberUserId(user.id)
|
||||
).actual;
|
||||
const maxAssociationCount = user.roles.includes("SUPPORTER")
|
||||
? ASSOCIATION.MAX_COUNT_SUPPORTER
|
||||
: ASSOCIATION.MAX_COUNT_REGULAR_USER;
|
||||
|
||||
if (associationCount.length >= maxAssociationCount) {
|
||||
return actionError<typeof createNewAssociationSchema>({
|
||||
msg: `Regular users can only be a member of ${maxAssociationCount} associations (supporters ${ASSOCIATION.MAX_COUNT_SUPPORTER})`,
|
||||
field: "name",
|
||||
});
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
await AssociationRepository.insert({
|
||||
name: data.name,
|
||||
userId: user.id,
|
||||
});
|
||||
try {
|
||||
await AssociationRepository.insert({
|
||||
name: result.data.name,
|
||||
userId: user.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LimitReachedError) {
|
||||
return { fieldErrors: { name: "forms:errors.maxAssociationsReached" } };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return redirect(associationsPage());
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { createNewAssociationSchema } from "~/features/associations/associations-schemas";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { associationsPage } from "~/utils/urls";
|
||||
|
||||
import { action } from "../actions/associations.new.server";
|
||||
export { action };
|
||||
|
||||
type FormFields = z.infer<typeof createNewAssociationSchema>;
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "scrims",
|
||||
i18n: ["scrims"],
|
||||
};
|
||||
|
||||
export default function AssociationsNewPage() {
|
||||
@@ -24,16 +20,8 @@ export default function AssociationsNewPage() {
|
||||
heading={t("scrims:associations.forms.title")}
|
||||
onCloseTo={associationsPage()}
|
||||
>
|
||||
<SendouForm
|
||||
schema={createNewAssociationSchema}
|
||||
defaultValues={{
|
||||
name: "",
|
||||
}}
|
||||
>
|
||||
<InputFormField<FormFields>
|
||||
label={t("scrims:associations.forms.name.title")}
|
||||
name="name"
|
||||
/>
|
||||
<SendouForm schema={createNewAssociationSchema}>
|
||||
{({ FormField }) => <FormField name="name" />}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
|
||||
@@ -46,23 +46,29 @@ export function BadgesSelector({
|
||||
</div>
|
||||
)}
|
||||
{showSelect ? (
|
||||
<select
|
||||
onBlur={onBlur}
|
||||
onChange={(e) =>
|
||||
onChange([...selectedBadges, Number(e.target.value)])
|
||||
}
|
||||
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
|
||||
data-testid="badges-selector"
|
||||
>
|
||||
<option>{t("common:badges.selector.select")}</option>
|
||||
{options
|
||||
.filter((badge) => !selectedBadges.includes(badge.id))
|
||||
.map((badge) => (
|
||||
<option key={badge.id} value={badge.id}>
|
||||
{badge.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
options.length === 0 ? (
|
||||
<div className="text-warning text-xs">
|
||||
{t("common:badges.selector.noneAvailable")}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
onBlur={() => onBlur?.()}
|
||||
onChange={(e) =>
|
||||
onChange([...selectedBadges, Number(e.target.value)])
|
||||
}
|
||||
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
|
||||
data-testid="badges-selector"
|
||||
>
|
||||
<option>{t("common:badges.selector.select")}</option>
|
||||
{options
|
||||
.filter((badge) => !selectedBadges.includes(badge.id))
|
||||
.map((badge) => (
|
||||
<option key={badge.id} value={badge.id}>
|
||||
{badge.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -9,12 +9,11 @@ import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
weaponIdHasAlts,
|
||||
weaponIdToArrayWithAlts,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { weaponIdToArrayWithAlts } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { LimitReachedError } from "~/utils/errors";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import { BUILD } from "./builds-constants";
|
||||
import { sortAbilities } from "./core/ability-sorting.server";
|
||||
|
||||
export async function allByUserId(
|
||||
@@ -94,9 +93,9 @@ interface CreateArgs {
|
||||
title: TablesInsertable["Build"]["title"];
|
||||
description: TablesInsertable["Build"]["description"];
|
||||
modes: Array<ModeShort> | null;
|
||||
headGearSplId: TablesInsertable["Build"]["headGearSplId"];
|
||||
clothesGearSplId: TablesInsertable["Build"]["clothesGearSplId"];
|
||||
shoesGearSplId: TablesInsertable["Build"]["shoesGearSplId"];
|
||||
headGearSplId: number | null;
|
||||
clothesGearSplId: number | null;
|
||||
shoesGearSplId: number | null;
|
||||
weaponSplIds: Array<BuildWeapon["weaponSplId"]>;
|
||||
abilities: BuildAbilitiesTuple;
|
||||
private: TablesInsertable["Build"]["private"];
|
||||
@@ -123,9 +122,9 @@ async function createInTrx({
|
||||
.sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)),
|
||||
)
|
||||
: null,
|
||||
headGearSplId: args.headGearSplId,
|
||||
clothesGearSplId: args.clothesGearSplId,
|
||||
shoesGearSplId: args.shoesGearSplId,
|
||||
headGearSplId: args.headGearSplId ?? -1,
|
||||
clothesGearSplId: args.clothesGearSplId ?? -1,
|
||||
shoesGearSplId: args.shoesGearSplId ?? -1,
|
||||
private: args.private,
|
||||
})
|
||||
.returningAll()
|
||||
@@ -182,7 +181,19 @@ async function createInTrx({
|
||||
}
|
||||
|
||||
export async function create(args: CreateArgs) {
|
||||
return db.transaction().execute(async (trx) => createInTrx({ args, trx }));
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await createInTrx({ args, trx });
|
||||
|
||||
const { count } = await trx
|
||||
.selectFrom("Build")
|
||||
.select((eb) => eb.fn.countAll<number>().as("count"))
|
||||
.where("ownerId", "=", args.ownerId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
if (count > BUILD.MAX_COUNT) {
|
||||
throw new LimitReachedError("Max amount of builds reached");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function update(args: CreateArgs & { id: number }) {
|
||||
@@ -196,6 +207,16 @@ export function deleteById(id: number) {
|
||||
return db.deleteFrom("Build").where("id", "=", id).execute();
|
||||
}
|
||||
|
||||
export async function ownerIdById(buildId: number) {
|
||||
const result = await db
|
||||
.selectFrom("Build")
|
||||
.select("ownerId")
|
||||
.where("id", "=", buildId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return result.ownerId;
|
||||
}
|
||||
|
||||
export async function abilityPointAverages(weaponSplId?: MainWeaponId | null) {
|
||||
return db
|
||||
.selectFrom("BuildAbility")
|
||||
@@ -252,8 +273,80 @@ export async function allByWeaponId(
|
||||
options: { limit: number; sortAbilities?: boolean },
|
||||
) {
|
||||
const { limit, sortAbilities: shouldSortAbilities = false } = options;
|
||||
const weaponIds = weaponIdToArrayWithAlts(weaponId);
|
||||
|
||||
let query = db
|
||||
let rows: Awaited<ReturnType<typeof buildsByWeaponIdQuery>>;
|
||||
|
||||
if (weaponIds.length === 1) {
|
||||
rows = await buildsByWeaponIdQuery(weaponIds[0], limit);
|
||||
} else {
|
||||
// For weapons with alts, run separate queries and merge.
|
||||
// This allows each query to use the covering index for ordering,
|
||||
// which is ~6x faster than using IN with multiple values.
|
||||
const allResults = await Promise.all(
|
||||
weaponIds.map((id) => buildsByWeaponIdQuery(id, limit)),
|
||||
);
|
||||
|
||||
const seenBuildIds = new Set<number>();
|
||||
type BuildRow = Awaited<ReturnType<typeof buildsByWeaponIdQuery>>[number];
|
||||
const merged: BuildRow[] = [];
|
||||
|
||||
// Merge results maintaining sort order (tier asc, isTop500 desc, updatedAt desc)
|
||||
// Since each query returns sorted results, we can interleave them
|
||||
const pointers = allResults.map(() => 0);
|
||||
|
||||
while (merged.length < limit) {
|
||||
let bestIdx = -1;
|
||||
let bestRow: BuildRow | null = null;
|
||||
|
||||
for (let i = 0; i < allResults.length; i++) {
|
||||
while (
|
||||
pointers[i] < allResults[i].length &&
|
||||
seenBuildIds.has(allResults[i][pointers[i]].id)
|
||||
) {
|
||||
pointers[i]++;
|
||||
}
|
||||
|
||||
if (pointers[i] >= allResults[i].length) continue;
|
||||
|
||||
const row = allResults[i][pointers[i]];
|
||||
|
||||
if (
|
||||
!bestRow ||
|
||||
row.bwTier < bestRow.bwTier ||
|
||||
(row.bwTier === bestRow.bwTier &&
|
||||
row.bwIsTop500 > bestRow.bwIsTop500) ||
|
||||
(row.bwTier === bestRow.bwTier &&
|
||||
row.bwIsTop500 === bestRow.bwIsTop500 &&
|
||||
row.bwUpdatedAt > bestRow.bwUpdatedAt)
|
||||
) {
|
||||
bestIdx = i;
|
||||
bestRow = row;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx === -1 || !bestRow) break;
|
||||
|
||||
seenBuildIds.add(bestRow.id);
|
||||
merged.push(bestRow);
|
||||
pointers[bestIdx]++;
|
||||
}
|
||||
|
||||
rows = merged;
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
|
||||
|
||||
return {
|
||||
...row,
|
||||
abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildsByWeaponIdQuery(weaponSplId: MainWeaponId, limit: number) {
|
||||
return db
|
||||
.selectFrom("BuildWeapon")
|
||||
.innerJoin("Build", "Build.id", "BuildWeapon.buildId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "Build.ownerId")
|
||||
@@ -268,6 +361,9 @@ export async function allByWeaponId(
|
||||
"Build.updatedAt",
|
||||
"Build.private",
|
||||
"PlusTier.tier as plusTier",
|
||||
"BuildWeapon.tier as bwTier",
|
||||
"BuildWeapon.isTop500 as bwIsTop500",
|
||||
"BuildWeapon.updatedAt as bwUpdatedAt",
|
||||
withAbilities(eb),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
@@ -284,26 +380,12 @@ export async function allByWeaponId(
|
||||
).as("owner"),
|
||||
])
|
||||
.where("Build.private", "=", 0)
|
||||
.where("BuildWeapon.weaponSplId", "in", weaponIdToArrayWithAlts(weaponId))
|
||||
.where("BuildWeapon.weaponSplId", "=", weaponSplId)
|
||||
.orderBy("BuildWeapon.tier", "asc")
|
||||
.orderBy("BuildWeapon.isTop500", "desc")
|
||||
.orderBy("BuildWeapon.updatedAt", "desc")
|
||||
.limit(limit);
|
||||
|
||||
if (weaponIdHasAlts(weaponId)) {
|
||||
query = query.groupBy("BuildWeapon.buildId");
|
||||
}
|
||||
|
||||
const rows = await query.execute();
|
||||
|
||||
return rows.map((row) => {
|
||||
const abilities = dbAbilitiesToArrayOfArrays(row.abilities);
|
||||
|
||||
return {
|
||||
...row,
|
||||
abilities: shouldSortAbilities ? sortAbilities(abilities) : abilities,
|
||||
};
|
||||
});
|
||||
.limit(limit)
|
||||
.execute();
|
||||
}
|
||||
|
||||
function withAbilities(eb: ExpressionBuilder<DB, "Build">) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newCalendarEventActionSchema,
|
||||
parseAsync: true,
|
||||
});
|
||||
|
||||
const isEditing = Boolean(data.eventToEditId);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Funnel } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useSearchParams } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { InputGroupFormField } from "~/components/form/InputGroupFormField";
|
||||
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
|
||||
import { ToggleFormField } from "~/components/form/ToggleFormField";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { calendarFiltersFormSchema } from "~/features/calendar/calendar-schemas";
|
||||
import type { CalendarFilters } from "~/features/calendar/calendar-types";
|
||||
import { TagsFormField } from "~/features/calendar/components/TagsFormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
|
||||
type FormValues = z.infer<typeof calendarFiltersFormSchema>;
|
||||
|
||||
export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
@@ -47,18 +42,6 @@ export function FiltersDialog({ filters }: { filters: CalendarFilters }) {
|
||||
);
|
||||
}
|
||||
|
||||
const TAGS_TO_OMIT: Array<CalendarEventTag> = [
|
||||
"CARDS",
|
||||
"SR",
|
||||
"S1",
|
||||
"S2",
|
||||
"SZ",
|
||||
"TW",
|
||||
"ONES",
|
||||
"DUOS",
|
||||
"TRIOS",
|
||||
] as const;
|
||||
|
||||
function FiltersForm({
|
||||
filters,
|
||||
closeDialog,
|
||||
@@ -67,156 +50,58 @@ function FiltersForm({
|
||||
closeDialog: () => void;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["game-misc", "calendar"]);
|
||||
const methods = useForm({
|
||||
resolver: standardSchemaResolver(calendarFiltersFormSchema),
|
||||
defaultValues: filters,
|
||||
});
|
||||
const fetcher = useFetcher<any>();
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const filtersToSearchParams = (newFilters: CalendarFilters) => {
|
||||
const handleApply = (values: FormValues) => {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("filters", JSON.stringify(newFilters));
|
||||
prev.set("filters", JSON.stringify(values));
|
||||
return prev;
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
const onApply = React.useCallback(
|
||||
methods.handleSubmit((values) => {
|
||||
filtersToSearchParams(values as CalendarFilters);
|
||||
closeDialog();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onApplyAndPersist = React.useCallback(
|
||||
methods.handleSubmit((values) =>
|
||||
fetcher.submit(values as Parameters<typeof fetcher.submit>[0], {
|
||||
method: "post",
|
||||
encType: "application/json",
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form
|
||||
className="stack md-plus items-start"
|
||||
onSubmit={onApplyAndPersist}
|
||||
>
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.modes")}
|
||||
name={"modes" as const}
|
||||
values={[
|
||||
{ label: t("game-misc:MODE_LONG_TW"), value: "TW" },
|
||||
{ label: t("game-misc:MODE_LONG_SZ"), value: "SZ" },
|
||||
{ label: t("game-misc:MODE_LONG_TC"), value: "TC" },
|
||||
{ label: t("game-misc:MODE_LONG_RM"), value: "RM" },
|
||||
{ label: t("game-misc:MODE_LONG_CB"), value: "CB" },
|
||||
{ label: t("game-misc:MODE_LONG_SR"), value: "SR" },
|
||||
{ label: t("game-misc:MODE_LONG_TB"), value: "TB" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.exactModes")}
|
||||
name={"modesExact" as const}
|
||||
bottomText={t("calendar:filter.exactModesBottom")}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.games")}
|
||||
name={"games" as const}
|
||||
values={[
|
||||
{ label: t("game-misc:GAME_S1"), value: "S1" },
|
||||
{ label: t("game-misc:GAME_S2"), value: "S2" },
|
||||
{ label: t("game-misc:GAME_S3"), value: "S3" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="checkbox"
|
||||
label={t("calendar:filter.vs")}
|
||||
name={"preferredVersus" as const}
|
||||
values={[
|
||||
{ label: "4v4", value: "4v4" },
|
||||
{ label: "3v3", value: "3v3" },
|
||||
{ label: "2v2", value: "2v2" },
|
||||
{ label: "1v1", value: "1v1" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<InputGroupFormField<CalendarFilters>
|
||||
type="radio"
|
||||
label={t("calendar:filter.startTime")}
|
||||
name={"preferredStartTime" as const}
|
||||
values={[
|
||||
{ label: t("calendar:filter.startTime.any"), value: "ANY" },
|
||||
{ label: t("calendar:filter.startTime.eu"), value: "EU" },
|
||||
{ label: t("calendar:filter.startTime.na"), value: "NA" },
|
||||
{ label: t("calendar:filter.startTime.au"), value: "AU" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<TagsFormField<CalendarFilters>
|
||||
label={t("calendar:filter.tagsIncluded")}
|
||||
name={"tagsIncluded" as const}
|
||||
tagsToOmit={TAGS_TO_OMIT}
|
||||
/>
|
||||
|
||||
<TagsFormField<CalendarFilters>
|
||||
label={t("calendar:filter.tagsExcluded")}
|
||||
name={"tagsExcluded" as const}
|
||||
tagsToOmit={TAGS_TO_OMIT}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.isSendou")}
|
||||
name={"isSendou" as const}
|
||||
/>
|
||||
|
||||
<ToggleFormField<CalendarFilters>
|
||||
label={t("calendar:filter.isRanked")}
|
||||
name={"isRanked" as const}
|
||||
/>
|
||||
|
||||
<InputFormField<CalendarFilters>
|
||||
label={t("calendar:filter.minTeamCount")}
|
||||
type="number"
|
||||
name={"minTeamCount" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.orgsIncluded")}
|
||||
name={"orgsIncluded" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.orgsExcluded")}
|
||||
name={"orgsExcluded" as const}
|
||||
/>
|
||||
|
||||
<TextArrayFormField<CalendarFilters>
|
||||
label={t("calendar:filter.authorIdsExcluded")}
|
||||
name={"authorIdsExcluded" as const}
|
||||
bottomText={t("calendar:filter.authorIdsExcludedBottom")}
|
||||
/>
|
||||
|
||||
<div className="stack horizontal md justify-center mt-6 w-full">
|
||||
<SendouButton onPress={() => onApply()}>
|
||||
{t("calendar:filter.apply")}
|
||||
</SendouButton>
|
||||
{user ? (
|
||||
<SubmitButton variant="outlined" state={fetcher.state}>
|
||||
{t("calendar:filter.applyAndDefault")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</FormProvider>
|
||||
<SendouForm
|
||||
schema={calendarFiltersFormSchema}
|
||||
defaultValues={filters as unknown as FormValues}
|
||||
onApply={handleApply}
|
||||
submitButtonText={t("calendar:filter.apply")}
|
||||
className="stack md-plus items-start"
|
||||
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="modes" />
|
||||
<FormField name="modesExact" />
|
||||
<FormField name="games" />
|
||||
<FormField name="preferredVersus" />
|
||||
<FormField name="preferredStartTime" />
|
||||
<FormField name="tagsIncluded" />
|
||||
<FormField name="tagsExcluded" />
|
||||
<FormField name="isSendou" />
|
||||
<FormField name="isRanked" />
|
||||
<FormField name="minTeamCount" />
|
||||
<FormField name="orgsIncluded" />
|
||||
<FormField name="orgsExcluded" />
|
||||
<FormField name="authorIdsExcluded" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyAndPersistButton() {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const { values, submitToServer, fetcherState } = useFormFieldContext();
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
onPress={() => submitToServer(values as CalendarFilters)}
|
||||
isDisabled={fetcherState !== "idle"}
|
||||
>
|
||||
{t("calendar:filter.applyAndDefault")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Tag, TagGroup, TagList } from "react-aria-components";
|
||||
import {
|
||||
Controller,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import type { CalendarEventTag } from "~/db/tables";
|
||||
import { CALENDAR_EVENT } from "~/features/calendar/calendar-constants";
|
||||
import { tags as allTags } from "../calendar-constants";
|
||||
|
||||
import styles from "./TagsFormField.module.css";
|
||||
|
||||
export function TagsFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
tagsToOmit,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
bottomText?: string;
|
||||
tagsToOmit?: Array<CalendarEventTag>;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name={name}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<SelectableTags
|
||||
selectedTags={value}
|
||||
onSelectionChange={onChange}
|
||||
tagsToOmit={tagsToOmit}
|
||||
ref={ref}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectableTags = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
selectedTags: Array<CalendarEventTag>;
|
||||
tagsToOmit?: Array<CalendarEventTag>;
|
||||
onSelectionChange: (selectedTags: Array<CalendarEventTag>) => void;
|
||||
}
|
||||
>(({ selectedTags, tagsToOmit, onSelectionChange }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const availableTags = tagsToOmit
|
||||
? CALENDAR_EVENT.TAGS.filter((tag) => !tagsToOmit?.includes(tag))
|
||||
: CALENDAR_EVENT.TAGS;
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
className={styles.tagGroup}
|
||||
selectionMode="multiple"
|
||||
selectedKeys={selectedTags}
|
||||
onSelectionChange={(newSelection) =>
|
||||
onSelectionChange(Array.from(newSelection) as CalendarEventTag[])
|
||||
}
|
||||
aria-label="Select tags"
|
||||
ref={ref}
|
||||
>
|
||||
<TagList className={styles.tagList}>
|
||||
{availableTags.map((tag) => {
|
||||
return (
|
||||
<Tag
|
||||
key={tag}
|
||||
id={tag}
|
||||
className={styles.tag}
|
||||
style={{ "--tag-color": allTags[tag].color }}
|
||||
>
|
||||
{t(`tag.name.${tag}`)}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</TagList>
|
||||
</TagGroup>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
`);
|
||||
|
||||
14
app/features/live-streams/LiveStreamRepository.server.ts
Normal file
14
app/features/live-streams/LiveStreamRepository.server.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { TablesInsertable } from "~/db/tables";
|
||||
|
||||
export function replaceAll(
|
||||
streams: Omit<TablesInsertable["LiveStream"], "id">[],
|
||||
) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("LiveStream").execute();
|
||||
|
||||
if (streams.length > 0) {
|
||||
await trx.insertInto("LiveStream").values(streams).execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,35 +5,42 @@ import type { Tables } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { userIsBanned } from "~/features/ban/core/banned.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
actionError,
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { scrimsPage } from "~/utils/urls";
|
||||
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
|
||||
import * as TeamRepository from "../../team/TeamRepository.server";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
|
||||
import {
|
||||
type fromSchema,
|
||||
type newRequestSchema,
|
||||
type RANGE_END_OPTIONS,
|
||||
scrimsNewActionSchema,
|
||||
scrimsNewFormSchema,
|
||||
} from "../scrims-schemas";
|
||||
import type { LutiDiv } from "../scrims-types";
|
||||
import { serializeLutiDiv } from "../scrims-utils";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: scrimsNewActionSchema,
|
||||
schema: scrimsNewFormSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
if (data.from.mode === "PICKUP") {
|
||||
if (data.from.users.includes(user.id)) {
|
||||
return actionError<typeof newRequestSchema>({
|
||||
@@ -55,11 +62,13 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
? resolveRangeEndToDate(data.at, data.rangeEnd)
|
||||
: null;
|
||||
|
||||
const resolvedDivs = data.divs ? resolveDivs(data.divs) : null;
|
||||
|
||||
await ScrimPostRepository.insert({
|
||||
at: dateToDatabaseTimestamp(data.at),
|
||||
rangeEnd: rangeEndDate ? dateToDatabaseTimestamp(rangeEndDate) : null,
|
||||
maxDiv: data.divs ? serializeLutiDiv(data.divs.max!) : null,
|
||||
minDiv: data.divs ? serializeLutiDiv(data.divs.min!) : null,
|
||||
maxDiv: resolvedDivs?.[0] ? serializeLutiDiv(resolvedDivs[0]) : null,
|
||||
minDiv: resolvedDivs?.[1] ? serializeLutiDiv(resolvedDivs[1]) : null,
|
||||
text: data.postText,
|
||||
managedByAnyone: data.managedByAnyone,
|
||||
maps:
|
||||
@@ -214,3 +223,18 @@ function resolveRangeEndToDate(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDivs(
|
||||
divs: [LutiDiv | null, LutiDiv | null],
|
||||
): [LutiDiv | null, LutiDiv | null] {
|
||||
const [max, min] = divs;
|
||||
if (!max || !min) return divs;
|
||||
|
||||
const maxIndex = LUTI_DIVS.indexOf(max);
|
||||
const minIndex = LUTI_DIVS.indexOf(min);
|
||||
|
||||
if (minIndex < maxIndex) {
|
||||
return [min, max];
|
||||
}
|
||||
return divs;
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import type * as React from "react";
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "~/components/Label";
|
||||
import { FormMessage } from "../../../components/FormMessage";
|
||||
import { LUTI_DIVS } from "../scrims-constants";
|
||||
import type { LutiDiv } from "../scrims-types";
|
||||
|
||||
export function LutiDivsFormField() {
|
||||
const methods = useFormContext();
|
||||
|
||||
const error = methods.formState.errors.divs;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="divs"
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<LutiDivsSelector value={value} onChange={onChange} onBlur={onBlur} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type LutiDivEdit = {
|
||||
max: LutiDiv | null;
|
||||
min: LutiDiv | null;
|
||||
};
|
||||
|
||||
function LutiDivsSelector({
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: {
|
||||
value: LutiDivEdit | null;
|
||||
onChange: (value: LutiDivEdit | null) => void;
|
||||
onBlur: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
const onChangeMin = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
|
||||
|
||||
onChange(
|
||||
newValue || value?.max
|
||||
? { min: newValue, max: value?.max ?? null }
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeMax = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newValue = e.target.value === "" ? null : (e.target.value as LutiDiv);
|
||||
|
||||
onChange(
|
||||
newValue || value?.min
|
||||
? { max: newValue, min: value?.min ?? null }
|
||||
: null,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack horizontal sm">
|
||||
<div>
|
||||
<Label htmlFor="max-div">{t("scrims:forms.divs.maxDiv.title")}</Label>
|
||||
<select
|
||||
id="max-div"
|
||||
value={value?.max ?? ""}
|
||||
onChange={onChangeMax}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{LUTI_DIVS.map((div) => (
|
||||
<option key={div} value={div}>
|
||||
{div}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="min-div">{t("scrims:forms.divs.minDiv.title")}</Label>
|
||||
<select
|
||||
id="min-div"
|
||||
value={value?.min ?? ""}
|
||||
onChange={onChangeMin}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{LUTI_DIVS.map((div) => (
|
||||
<option key={div} value={div}>
|
||||
{div}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Funnel } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useSearchParams } from "react-router";
|
||||
import { useSearchParams } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { InputFormField } from "~/components/form/InputFormField";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { ScrimFilters } from "~/features/scrims/scrims-types";
|
||||
import { scrimsFiltersSchema } from "../scrims-schemas";
|
||||
import { LutiDivsFormField } from "./LutiDivsFormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { scrimsFiltersFormSchema } from "../scrims-schemas";
|
||||
import type { LutiDiv } from "../scrims-types";
|
||||
|
||||
type FormValues = z.infer<typeof scrimsFiltersFormSchema>;
|
||||
|
||||
export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
@@ -44,6 +44,26 @@ export function ScrimFiltersDialog({ filters }: { filters: ScrimFilters }) {
|
||||
);
|
||||
}
|
||||
|
||||
function filtersToFormValues(filters: ScrimFilters): FormValues {
|
||||
return {
|
||||
weekdayTimes: filters.weekdayTimes,
|
||||
weekendTimes: filters.weekendTimes,
|
||||
divs: filters.divs ? [filters.divs.max, filters.divs.min] : [null, null],
|
||||
};
|
||||
}
|
||||
|
||||
function formValuesToFilters(values: FormValues): ScrimFilters {
|
||||
const [max, min] = values.divs ?? [null, null];
|
||||
return {
|
||||
weekdayTimes: values.weekdayTimes,
|
||||
weekendTimes: values.weekendTimes,
|
||||
divs:
|
||||
max || min
|
||||
? { max: max as LutiDiv | null, min: min as LutiDiv | null }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FiltersForm({
|
||||
filters,
|
||||
closeDialog,
|
||||
@@ -53,92 +73,56 @@ function FiltersForm({
|
||||
}) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
const methods = useForm({
|
||||
resolver: standardSchemaResolver(scrimsFiltersSchema),
|
||||
defaultValues: filters,
|
||||
});
|
||||
const fetcher = useFetcher<any>();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const filtersToSearchParams = (newFilters: ScrimFilters) => {
|
||||
const defaultValues = filtersToFormValues(filters);
|
||||
|
||||
const handleApply = (values: FormValues) => {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("filters", JSON.stringify(newFilters));
|
||||
prev.set("filters", JSON.stringify(formValuesToFilters(values)));
|
||||
return prev;
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<SendouForm
|
||||
schema={scrimsFiltersFormSchema}
|
||||
defaultValues={defaultValues}
|
||||
onApply={handleApply}
|
||||
submitButtonText={t("scrims:filters.apply")}
|
||||
className="stack md-plus items-start"
|
||||
secondarySubmit={user ? <ApplyAndPersistButton /> : null}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="weekdayTimes" />
|
||||
<FormField name="weekendTimes" />
|
||||
<FormField name="divs" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplyAndPersistButton() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const { values, submitToServer, fetcherState } = useFormFieldContext();
|
||||
|
||||
const handlePress = () => {
|
||||
submitToServer({
|
||||
_action: "PERSIST_SCRIM_FILTERS",
|
||||
filters: formValuesToFilters(values as FormValues),
|
||||
});
|
||||
};
|
||||
|
||||
const onApply = React.useCallback(
|
||||
methods.handleSubmit((values) => {
|
||||
filtersToSearchParams(values as ScrimFilters);
|
||||
closeDialog();
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const onApplyAndPersist = React.useCallback(
|
||||
methods.handleSubmit((values) =>
|
||||
fetcher.submit(
|
||||
// @ts-expect-error TODO: fix
|
||||
{
|
||||
_action: "PERSIST_SCRIM_FILTERS",
|
||||
filters: values as Parameters<typeof fetcher.submit>[0],
|
||||
},
|
||||
{
|
||||
method: "post",
|
||||
encType: "application/json",
|
||||
},
|
||||
),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form
|
||||
className="stack md-plus items-start"
|
||||
onSubmit={onApplyAndPersist}
|
||||
>
|
||||
<input type="hidden" name="_action" value="PERSIST_SCRIM_FILTERS" />
|
||||
<div className="stack sm horizontal">
|
||||
<InputFormField<ScrimFilters>
|
||||
label={t("scrims:filters.weekdayStart")}
|
||||
name={"weekdayTimes.start" as const}
|
||||
type="time"
|
||||
/>
|
||||
<InputFormField<ScrimFilters>
|
||||
label={t("scrims:filters.weekdayEnd")}
|
||||
name={"weekdayTimes.end" as const}
|
||||
type="time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="stack sm horizontal">
|
||||
<InputFormField<ScrimFilters>
|
||||
label={t("scrims:filters.weekendStart")}
|
||||
name={"weekendTimes.start" as const}
|
||||
type="time"
|
||||
/>
|
||||
<InputFormField<ScrimFilters>
|
||||
label={t("scrims:filters.weekendEnd")}
|
||||
name={"weekendTimes.end" as const}
|
||||
type="time"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LutiDivsFormField />
|
||||
|
||||
<div className="stack horizontal md justify-center mt-6 w-full">
|
||||
<SendouButton onPress={() => onApply()}>
|
||||
{t("scrims:filters.apply")}
|
||||
</SendouButton>
|
||||
{user ? (
|
||||
<SubmitButton variant="outlined" state={fetcher.state}>
|
||||
{t("scrims:filters.applyAndDefault")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</FormProvider>
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
onPress={handlePress}
|
||||
isDisabled={fetcherState !== "idle"}
|
||||
>
|
||||
{t("scrims:filters.applyAndDefault")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SelectFormField } from "~/components/form/SelectFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { loader as scrimsLoader } from "../loaders/scrims.server";
|
||||
import type { NewRequestFormFields } from "../routes/scrims";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import { newRequestSchema } from "../scrims-schemas";
|
||||
import { scrimRequestFormSchema } from "../scrims-schemas";
|
||||
import type { ScrimPost } from "../scrims-types";
|
||||
import { generateTimeOptions } from "../scrims-utils";
|
||||
import { WithFormField } from "./WithFormField";
|
||||
@@ -32,7 +30,7 @@ export function ScrimRequestModal({
|
||||
databaseTimestampToDate(post.at),
|
||||
databaseTimestampToDate(post.rangeEnd),
|
||||
).map((timestamp) => ({
|
||||
value: timestamp,
|
||||
value: String(timestamp),
|
||||
label: formatTime(new Date(timestamp)),
|
||||
}))
|
||||
: [];
|
||||
@@ -40,9 +38,8 @@ export function ScrimRequestModal({
|
||||
return (
|
||||
<SendouDialog heading={t("scrims:requestModal.title")} onClose={close}>
|
||||
<SendouForm
|
||||
schema={newRequestSchema}
|
||||
schema={scrimRequestFormSchema}
|
||||
defaultValues={{
|
||||
_action: "NEW_REQUEST",
|
||||
scrimPostId: post.id,
|
||||
from:
|
||||
data.teams.length > 0
|
||||
@@ -54,32 +51,31 @@ export function ScrimRequestModal({
|
||||
) as unknown as number[],
|
||||
},
|
||||
message: "",
|
||||
at: post.rangeEnd ? (timeOptions[0]?.value as unknown as Date) : null,
|
||||
at: post.rangeEnd && timeOptions[0] ? timeOptions[0].value : null,
|
||||
}}
|
||||
>
|
||||
<div className="font-semi-bold text-lighter italic">
|
||||
{new Intl.ListFormat(i18n.language).format(
|
||||
post.users.map((u) => u.username),
|
||||
)}
|
||||
</div>
|
||||
{post.text ? (
|
||||
<div className="text-sm text-lighter italic">{post.text}</div>
|
||||
) : null}
|
||||
<Divider />
|
||||
<WithFormField usersTeams={data.teams} />
|
||||
{post.rangeEnd ? (
|
||||
<SelectFormField<NewRequestFormFields>
|
||||
name="at"
|
||||
label={t("scrims:requestModal.at.label")}
|
||||
bottomText={t("scrims:requestModal.at.explanation")}
|
||||
values={timeOptions}
|
||||
/>
|
||||
) : null}
|
||||
<TextAreaFormField<NewRequestFormFields>
|
||||
name="message"
|
||||
label={t("scrims:requestModal.message.label")}
|
||||
maxLength={SCRIM.REQUEST_MESSAGE_MAX_LENGTH}
|
||||
/>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<div className="font-semi-bold text-lighter italic">
|
||||
{new Intl.ListFormat(i18n.language).format(
|
||||
post.users.map((u) => u.username),
|
||||
)}
|
||||
</div>
|
||||
{post.text ? (
|
||||
<div className="text-sm text-lighter italic">{post.text}</div>
|
||||
) : null}
|
||||
<Divider />
|
||||
<FormField name="from">
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<WithFormField usersTeams={data.teams} {...props} />
|
||||
)}
|
||||
</FormField>
|
||||
{post.rangeEnd ? (
|
||||
<FormField name="at" options={timeOptions} />
|
||||
) : null}
|
||||
<FormField name="message" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
|
||||
@@ -1,107 +1,117 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { SCRIM } from "~/features/scrims/scrims-constants";
|
||||
import {
|
||||
FormFieldWrapper,
|
||||
useTranslatedTexts,
|
||||
} from "~/form/fields/FormFieldWrapper";
|
||||
import { errorMessageId } from "~/form/utils";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import type { NewRequestFormFields } from "../routes/scrims";
|
||||
import type { fromSchema } from "../scrims-schemas";
|
||||
|
||||
interface FromFormFieldProps {
|
||||
type FromValue = z.infer<typeof fromSchema>;
|
||||
|
||||
interface WithFormFieldProps {
|
||||
usersTeams: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
members: Array<CommonUser>;
|
||||
}>;
|
||||
name: string;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
error: string | undefined;
|
||||
}
|
||||
|
||||
export function WithFormField({ usersTeams }: FromFormFieldProps) {
|
||||
export function WithFormField({
|
||||
usersTeams,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: WithFormFieldProps) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
const user = useUser();
|
||||
const methods = useFormContext<NewRequestFormFields>();
|
||||
const id = React.useId();
|
||||
const { translatedError } = useTranslatedTexts({ error });
|
||||
|
||||
const fromValue = value as FromValue | null;
|
||||
|
||||
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (e.target.value === "PICKUP") {
|
||||
onChange({
|
||||
mode: "PICKUP",
|
||||
users: nullFilledArray(SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ teamId: Number(e.target.value), mode: "TEAM" });
|
||||
};
|
||||
|
||||
const handleUserChange = (
|
||||
selectedUser: { id: number } | null,
|
||||
index: number,
|
||||
) => {
|
||||
if (!fromValue || fromValue.mode !== "PICKUP") return;
|
||||
|
||||
onChange({
|
||||
mode: "PICKUP",
|
||||
users: fromValue.users.map((u, j) =>
|
||||
j === index ? selectedUser?.id : u,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const selectValue = fromValue?.mode === "TEAM" ? fromValue.teamId : "PICKUP";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="with">{t("scrims:forms.with.title")}</Label>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="from"
|
||||
render={({ field: { onChange, onBlur, value }, fieldState }) => {
|
||||
const setTeam = (teamId: number) => {
|
||||
onChange({ teamId, mode: "TEAM" });
|
||||
};
|
||||
|
||||
const error =
|
||||
(fieldState.error as any)?.users ?? fieldState.error?.root;
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
id="with"
|
||||
className="w-max"
|
||||
value={value.mode === "TEAM" ? value.teamId : "PICKUP"}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "PICKUP") {
|
||||
onChange({
|
||||
mode: "PICKUP",
|
||||
users: nullFilledArray(
|
||||
SCRIM.MAX_PICKUP_SIZE_EXCLUDING_OWNER,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setTeam(Number(e.target.value));
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{usersTeams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
|
||||
</select>
|
||||
{value.mode === "PICKUP" ? (
|
||||
<div className="stack md mt-4">
|
||||
<UserSearch
|
||||
initialUserId={user!.id}
|
||||
isDisabled
|
||||
label={t("scrims:forms.with.user", { nth: 1 })}
|
||||
/>
|
||||
{value.users.map((userId, i) => (
|
||||
<UserSearch
|
||||
key={i}
|
||||
initialUserId={userId}
|
||||
onChange={(user) =>
|
||||
onChange({
|
||||
mode: "PICKUP",
|
||||
users: value.users.map((u, j) =>
|
||||
j === i ? user?.id : u,
|
||||
),
|
||||
})
|
||||
}
|
||||
isRequired={i < 3}
|
||||
label={t("scrims:forms.with.user", { nth: i + 2 })}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
<FormMessage type="error">
|
||||
{error.message as string}
|
||||
</FormMessage>
|
||||
) : (
|
||||
<FormMessage type="info">
|
||||
{t("scrims:forms.with.explanation")}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormFieldWrapper
|
||||
id={id}
|
||||
name={name}
|
||||
label={t("scrims:forms.with.title")}
|
||||
error={fromValue?.mode === "TEAM" ? error : undefined}
|
||||
>
|
||||
<select id={id} value={selectValue} onChange={handleSelectChange}>
|
||||
{usersTeams.map((team) => (
|
||||
<option key={team.id} value={team.id}>
|
||||
{team.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="PICKUP">{t("scrims:forms.with.pick-up")}</option>
|
||||
</select>
|
||||
{fromValue?.mode === "PICKUP" ? (
|
||||
<div className="stack md mt-4">
|
||||
<UserSearch
|
||||
initialUserId={user!.id}
|
||||
isDisabled
|
||||
label={t("scrims:forms.with.user", { nth: 1 })}
|
||||
/>
|
||||
{fromValue.users.map((userId, i) => (
|
||||
<UserSearch
|
||||
key={i}
|
||||
initialUserId={userId}
|
||||
onChange={(selectedUser) => handleUserChange(selectedUser, i)}
|
||||
isRequired={i < 3}
|
||||
label={t("scrims:forms.with.user", { nth: i + 2 })}
|
||||
/>
|
||||
))}
|
||||
{translatedError ? (
|
||||
<FormMessage type="error" id={errorMessageId(name)}>
|
||||
{translatedError}
|
||||
</FormMessage>
|
||||
) : (
|
||||
<FormMessage type="info">
|
||||
{t("scrims:forms.with.explanation")}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,16 @@ import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { Image } from "~/components/Image";
|
||||
import TimePopover from "~/components/TimePopover";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { SCRIM } from "~/features/scrims/scrims-constants";
|
||||
import { cancelScrimSchema } from "~/features/scrims/scrims-schemas";
|
||||
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
@@ -118,23 +115,13 @@ export default function ScrimPage() {
|
||||
);
|
||||
}
|
||||
|
||||
type FormFields = z.infer<typeof cancelScrimSchema>;
|
||||
|
||||
function CancelScrimForm() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
return (
|
||||
<SendouForm
|
||||
schema={cancelScrimSchema}
|
||||
defaultValues={{ reason: "" }}
|
||||
submitButtonTestId="cancel-scrim-submit"
|
||||
>
|
||||
<TextAreaFormField<FormFields>
|
||||
name="reason"
|
||||
label={t("cancelModal.scrim.reasonLabel")}
|
||||
maxLength={SCRIM.CANCEL_REASON_MAX_LENGTH}
|
||||
bottomText={t("scrims:cancelModal.scrim.reasonExplanation")}
|
||||
/>
|
||||
{({ FormField }) => <FormField name="reason" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
4
app/features/scrims/routes/scrims.new.module.css
Normal file
4
app/features/scrims/routes/scrims.new.module.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.datePickerFullWidth {
|
||||
--input-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
} from "~/utils/Test";
|
||||
import { action } from "../actions/scrims.new.server";
|
||||
import { loader } from "../loaders/scrims.server";
|
||||
import type { scrimsNewActionSchema } from "../scrims-schemas";
|
||||
import type { scrimsNewFormSchema } from "../scrims-schemas";
|
||||
|
||||
const newScrimAction = wrappedAction<typeof scrimsNewActionSchema>({
|
||||
const newScrimAction = wrappedAction<typeof scrimsNewFormSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const defaultNewScrimPostArgs: Parameters<typeof newScrimAction>[0] = {
|
||||
at: new Date(),
|
||||
rangeEnd: null,
|
||||
baseVisibility: "PUBLIC",
|
||||
divs: { min: null, max: null },
|
||||
divs: [null, null],
|
||||
from: {
|
||||
mode: "PICKUP",
|
||||
users: [1, 3, 4],
|
||||
|
||||
@@ -1,36 +1,33 @@
|
||||
import type { CalendarDateTime } from "@internationalized/date";
|
||||
import * as React from "react";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { SendouDatePicker } from "~/components/elements/DatePicker";
|
||||
import { TournamentSearch } from "~/components/elements/TournamentSearch";
|
||||
import { DateFormField } from "~/components/form/DateFormField";
|
||||
import { SelectFormField } from "~/components/form/SelectFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { ToggleFormField } from "~/components/form/ToggleFormField";
|
||||
import { Label } from "~/components/Label";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { errorMessageId } from "~/form/utils";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import { dateToDateValue } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { FormMessage } from "../../../components/FormMessage";
|
||||
import { Main } from "../../../components/Main";
|
||||
import { action } from "../actions/scrims.new.server";
|
||||
import { LutiDivsFormField } from "../components/LutiDivsFormField";
|
||||
import { WithFormField } from "../components/WithFormField";
|
||||
import { loader, type ScrimsNewLoaderData } from "../loaders/scrims.new.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import {
|
||||
MAX_SCRIM_POST_TEXT_LENGTH,
|
||||
RANGE_END_OPTIONS,
|
||||
scrimsNewActionSchema,
|
||||
} from "../scrims-schemas";
|
||||
import { scrimsNewFormSchema } from "../scrims-schemas";
|
||||
import styles from "./scrims.new.module.css";
|
||||
export { loader, action };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "scrims",
|
||||
};
|
||||
|
||||
type FormFields = z.infer<typeof scrimsNewActionSchema>;
|
||||
type FormFields = z.infer<typeof scrimsNewFormSchema>;
|
||||
|
||||
const DEFAULT_NOT_FOUND_VISIBILITY = {
|
||||
at: null,
|
||||
@@ -44,13 +41,12 @@ export default function NewScrimPage() {
|
||||
return (
|
||||
<Main>
|
||||
<SendouForm
|
||||
schema={scrimsNewActionSchema}
|
||||
heading={t("scrims:forms.title")}
|
||||
schema={scrimsNewFormSchema}
|
||||
title={t("scrims:forms.title")}
|
||||
defaultValues={{
|
||||
postText: "",
|
||||
at: new Date(),
|
||||
rangeEnd: null,
|
||||
divs: null,
|
||||
baseVisibility: "PUBLIC",
|
||||
notFoundVisibility: DEFAULT_NOT_FOUND_VISIBILITY,
|
||||
from:
|
||||
@@ -67,64 +63,39 @@ export default function NewScrimPage() {
|
||||
mapsTournamentId: null,
|
||||
}}
|
||||
>
|
||||
<WithFormField usersTeams={data.teams} />
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="from">
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<WithFormField usersTeams={data.teams} {...props} />
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<DateFormField<FormFields>
|
||||
label={t("scrims:forms.when.title")}
|
||||
name="at"
|
||||
bottomText={t("scrims:forms.when.explanation")}
|
||||
granularity="minute"
|
||||
/>
|
||||
<SelectFormField<FormFields>
|
||||
label={t("scrims:forms.rangeEnd.title")}
|
||||
name="rangeEnd"
|
||||
bottomText={t("scrims:forms.rangeEnd.explanation")}
|
||||
values={[
|
||||
{
|
||||
value: "",
|
||||
label: t("scrims:forms.rangeEnd.notFlexible"),
|
||||
},
|
||||
...RANGE_END_OPTIONS.map((option) => ({
|
||||
value: option,
|
||||
label: t(`scrims:forms.rangeEnd.${option}`),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<FormField name="at" />
|
||||
<FormField name="rangeEnd" />
|
||||
|
||||
<BaseVisibilityFormField associations={data.associations} />
|
||||
<FormField name="baseVisibility">
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<BaseVisibilityFormField
|
||||
associations={data.associations}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<NotFoundVisibilityFormField associations={data.associations} />
|
||||
<NotFoundVisibilityFormField associations={data.associations} />
|
||||
|
||||
<LutiDivsFormField />
|
||||
<FormField name="divs" />
|
||||
|
||||
<SelectFormField<FormFields>
|
||||
label={t("scrims:forms.maps.title")}
|
||||
name="maps"
|
||||
values={[
|
||||
{
|
||||
value: "NO_PREFERENCE",
|
||||
label: t("scrims:forms.maps.noPreference"),
|
||||
},
|
||||
{ value: "SZ", label: t("scrims:forms.maps.szOnly") },
|
||||
{ value: "RANKED", label: t("scrims:forms.maps.rankedOnly") },
|
||||
{ value: "ALL", label: t("scrims:forms.maps.allModes") },
|
||||
{ value: "TOURNAMENT", label: t("scrims:forms.maps.tournament") },
|
||||
]}
|
||||
/>
|
||||
<FormField name="maps" />
|
||||
|
||||
<TournamentSearchFormField />
|
||||
<TournamentSearchFormField />
|
||||
|
||||
<TextAreaFormField<FormFields>
|
||||
label={t("scrims:forms.text.title")}
|
||||
name="postText"
|
||||
maxLength={MAX_SCRIM_POST_TEXT_LENGTH}
|
||||
/>
|
||||
<FormField name="postText" />
|
||||
|
||||
<ToggleFormField<FormFields>
|
||||
label={t("scrims:forms.managedByAnyone.title")}
|
||||
name="managedByAnyone"
|
||||
bottomText={t("scrims:forms.managedByAnyone.explanation")}
|
||||
/>
|
||||
<FormField name="managedByAnyone" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
@@ -132,20 +103,30 @@ export default function NewScrimPage() {
|
||||
|
||||
function BaseVisibilityFormField({
|
||||
associations,
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: {
|
||||
associations: ScrimsNewLoaderData["associations"];
|
||||
name: string;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
error: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const methods = useFormContext<FormFields>();
|
||||
|
||||
const error = methods.formState.errors.baseVisibility;
|
||||
const id = React.useId();
|
||||
|
||||
const noAssociations =
|
||||
associations.virtual.length === 0 && associations.actual.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="visibility">{t("scrims:forms.visibility.title")}</Label>
|
||||
<FormFieldWrapper
|
||||
id={id}
|
||||
name={name}
|
||||
label={t("scrims:forms.visibility.title")}
|
||||
error={error}
|
||||
>
|
||||
{noAssociations ? (
|
||||
<FormMessage type="info">
|
||||
{t("scrims:forms.visibility.noneAvailable")}
|
||||
@@ -153,15 +134,12 @@ function BaseVisibilityFormField({
|
||||
) : (
|
||||
<AssociationSelect
|
||||
associations={associations}
|
||||
id="visibility"
|
||||
{...methods.register("baseVisibility")}
|
||||
id={id}
|
||||
value={String(value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,68 +148,112 @@ function NotFoundVisibilityFormField({
|
||||
}: {
|
||||
associations: ScrimsNewLoaderData["associations"];
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const baseVisibility = useWatch<FormFields>({
|
||||
name: "baseVisibility",
|
||||
});
|
||||
const date = useWatch<FormFields>({ name: "notFoundVisibility.at" }) ?? "";
|
||||
const methods = useFormContext<FormFields>();
|
||||
const { t } = useTranslation(["scrims", "forms"]);
|
||||
const { values, setValue, clientErrors, serverErrors } =
|
||||
useFormFieldContext();
|
||||
const baseVisibility = values.baseVisibility as string;
|
||||
const notFoundVisibility =
|
||||
values.notFoundVisibility as FormFields["notFoundVisibility"];
|
||||
|
||||
React.useEffect(() => {
|
||||
const prevBaseVisibility = React.useRef(baseVisibility);
|
||||
if (prevBaseVisibility.current !== baseVisibility) {
|
||||
prevBaseVisibility.current = baseVisibility;
|
||||
if (baseVisibility === "PUBLIC") {
|
||||
methods.setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
|
||||
setValue("notFoundVisibility", DEFAULT_NOT_FOUND_VISIBILITY);
|
||||
}
|
||||
}, [baseVisibility, methods.setValue]);
|
||||
}
|
||||
|
||||
const error = methods.formState.errors.notFoundVisibility;
|
||||
const error =
|
||||
serverErrors.notFoundVisibility ?? clientErrors.notFoundVisibility;
|
||||
|
||||
const noAssociations =
|
||||
associations.virtual.length === 0 && associations.actual.length === 0;
|
||||
|
||||
if (noAssociations || baseVisibility === "PUBLIC") return null;
|
||||
|
||||
const handleDateChange = (val: CalendarDateTime | null) => {
|
||||
if (val) {
|
||||
const date = new Date(
|
||||
val.year,
|
||||
val.month - 1,
|
||||
val.day,
|
||||
val.hour,
|
||||
val.minute,
|
||||
);
|
||||
setValue("notFoundVisibility", {
|
||||
...notFoundVisibility,
|
||||
at: date,
|
||||
});
|
||||
} else {
|
||||
setValue("notFoundVisibility", {
|
||||
...notFoundVisibility,
|
||||
at: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssociationChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setValue("notFoundVisibility", {
|
||||
...notFoundVisibility,
|
||||
forAssociation: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const dateValue = notFoundVisibility.at
|
||||
? dateToDateValue(new Date(notFoundVisibility.at))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal sm">
|
||||
<DateFormField<FormFields>
|
||||
label={t("scrims:forms.notFoundVisibility.title")}
|
||||
name="notFoundVisibility.at"
|
||||
granularity="minute"
|
||||
/>
|
||||
{date ? (
|
||||
<div>
|
||||
<div className={styles.datePickerFullWidth}>
|
||||
<SendouDatePicker
|
||||
label={t("scrims:forms.notFoundVisibility.title")}
|
||||
granularity="minute"
|
||||
errorText={error ? t(`forms:${error}` as never) : undefined}
|
||||
errorId={errorMessageId("notFoundVisibility")}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
bottomText={
|
||||
notFoundVisibility.at
|
||||
? undefined
|
||||
: t("scrims:forms.notFoundVisibility.explanation")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{notFoundVisibility.at ? (
|
||||
<div className="w-full">
|
||||
<Label htmlFor="not-found-visibility">
|
||||
{t("scrims:forms.visibility.title")}
|
||||
</Label>
|
||||
<AssociationSelect
|
||||
associations={associations}
|
||||
id="not-found-visibility"
|
||||
{...methods.register("notFoundVisibility.forAssociation")}
|
||||
value={String(notFoundVisibility.forAssociation)}
|
||||
onChange={handleAssociationChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
) : (
|
||||
<FormMessage type="info">
|
||||
{t("scrims:forms.notFoundVisibility.explanation")}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const AssociationSelect = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
{
|
||||
associations: ScrimsNewLoaderData["associations"];
|
||||
} & React.SelectHTMLAttributes<HTMLSelectElement>
|
||||
>(({ associations, ...rest }, ref) => {
|
||||
function AssociationSelect({
|
||||
associations,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
associations: ScrimsNewLoaderData["associations"];
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
|
||||
return (
|
||||
<select ref={ref} {...rest}>
|
||||
<select id={id} className="w-full" value={value} onChange={onChange}>
|
||||
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
|
||||
{associations.virtual.map((association) => (
|
||||
<option key={association} value={association}>
|
||||
@@ -245,40 +267,42 @@ const AssociationSelect = React.forwardRef<
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function TournamentSearchFormField() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const methods = useFormContext<FormFields>();
|
||||
const maps = useWatch<FormFields>({ name: "maps" });
|
||||
const { values, setValue, clientErrors, serverErrors } =
|
||||
useFormFieldContext();
|
||||
const maps = values.maps as string;
|
||||
const mapsTournamentId = values.mapsTournamentId as number | null;
|
||||
|
||||
const error = methods.formState.errors.mapsTournamentId;
|
||||
const error = serverErrors.mapsTournamentId ?? clientErrors.mapsTournamentId;
|
||||
|
||||
const prevMaps = React.useRef(maps);
|
||||
React.useEffect(() => {
|
||||
if (maps !== "TOURNAMENT") {
|
||||
methods.setValue("mapsTournamentId", null);
|
||||
if (prevMaps.current !== maps) {
|
||||
prevMaps.current = maps;
|
||||
if (maps !== "TOURNAMENT") {
|
||||
setValue("mapsTournamentId", null);
|
||||
}
|
||||
}
|
||||
}, [maps, methods]);
|
||||
}, [maps, setValue]);
|
||||
|
||||
if (maps !== "TOURNAMENT") return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="mapsTournamentId"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<TournamentSearch
|
||||
label={t("scrims:forms.mapsTournament.title")}
|
||||
initialTournamentId={value ?? undefined}
|
||||
onChange={(tournament) => onChange(tournament?.id)}
|
||||
/>
|
||||
)}
|
||||
<FormFieldWrapper
|
||||
id="mapsTournamentId"
|
||||
name="mapsTournamentId"
|
||||
error={error}
|
||||
>
|
||||
<TournamentSearch
|
||||
label={t("scrims:forms.mapsTournament.title")}
|
||||
initialTournamentId={mapsTournamentId ?? undefined}
|
||||
onChange={(tournament) =>
|
||||
setValue("mapsTournamentId", tournament?.id ?? null)
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
app/features/sendouq-settings/q-settings-schemas.ts
Normal file
38
app/features/sendouq-settings/q-settings-schemas.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
checkboxGroup,
|
||||
radioGroup,
|
||||
stringConstant,
|
||||
weaponPool,
|
||||
} from "~/form/fields";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "./q-settings-constants";
|
||||
|
||||
export const updateWeaponPoolSchema = z.object({
|
||||
_action: stringConstant("UPDATE_SENDOUQ_WEAPON_POOL"),
|
||||
weaponPool: weaponPool({
|
||||
label: "labels.weaponPool",
|
||||
maxCount: SENDOUQ_WEAPON_POOL_MAX_SIZE,
|
||||
}),
|
||||
});
|
||||
|
||||
const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({
|
||||
label: () => lang.name,
|
||||
value: lang.code,
|
||||
}));
|
||||
|
||||
export const updateVoiceChatSchema = z.object({
|
||||
_action: stringConstant("UPDATE_VC"),
|
||||
vc: radioGroup({
|
||||
label: "labels.voiceChat",
|
||||
items: [
|
||||
{ label: "options.voiceChat.yes", value: "YES" },
|
||||
{ label: "options.voiceChat.no", value: "NO" },
|
||||
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
|
||||
],
|
||||
}),
|
||||
languages: checkboxGroup({
|
||||
label: "labels.languages",
|
||||
items: LANGUAGE_OPTIONS,
|
||||
}),
|
||||
});
|
||||
@@ -15,29 +15,28 @@ import type { MetaFunction } from "react-router";
|
||||
import { useFetcher, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { ModeImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import type { Preference, UserMapModePreferences } from "~/db/tables";
|
||||
import {
|
||||
soundCodeToLocalStorageKey,
|
||||
soundVolume,
|
||||
} from "~/features/chat/chat-utils";
|
||||
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
navIconUrl,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
SETTINGS_PAGE,
|
||||
soundPath,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/q.settings.server";
|
||||
@@ -45,10 +44,11 @@ import { BANNED_MAPS } from "../banned-maps";
|
||||
import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker";
|
||||
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
|
||||
import { loader } from "../loaders/q.settings.server";
|
||||
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "../q-settings-constants";
|
||||
import {
|
||||
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
|
||||
SENDOUQ_WEAPON_POOL_MAX_SIZE,
|
||||
} from "../q-settings-constants";
|
||||
updateVoiceChatSchema,
|
||||
updateWeaponPoolSchema,
|
||||
} from "../q-settings-schemas";
|
||||
export { loader, action };
|
||||
|
||||
import styles from "./q.settings.module.css";
|
||||
@@ -253,8 +253,8 @@ function MapPicker() {
|
||||
}
|
||||
|
||||
function VoiceChat() {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<details>
|
||||
@@ -263,128 +263,34 @@ function VoiceChat() {
|
||||
<span>{t("q:settings.voiceChat.header")}</span> <Mic />
|
||||
</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
|
||||
<VoiceChatAbility />
|
||||
<Languages />
|
||||
<div>
|
||||
<SubmitButton
|
||||
size="big"
|
||||
className="mt-2 mx-auto"
|
||||
_action="UPDATE_VC"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<div className="mb-4 ml-2-5">
|
||||
<SendouForm
|
||||
schema={updateVoiceChatSchema}
|
||||
defaultValues={{
|
||||
vc: data.settings.vc,
|
||||
languages: data.settings.languages ?? [],
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="vc" />
|
||||
<FormField name="languages" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceChatAbility() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const label = (vc: Tables["User"]["vc"]) => {
|
||||
switch (vc) {
|
||||
case "YES":
|
||||
return t("q:settings.voiceChat.canVC.yes");
|
||||
case "NO":
|
||||
return t("q:settings.voiceChat.canVC.no");
|
||||
case "LISTEN_ONLY":
|
||||
return t("q:settings.voiceChat.canVC.listenOnly");
|
||||
default:
|
||||
assertUnreachable(vc);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label>{t("q:settings.voiceChat.canVC.header")}</label>
|
||||
{(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
|
||||
return (
|
||||
<div key={option} className="stack sm horizontal items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="vc"
|
||||
id={option}
|
||||
value={option}
|
||||
required
|
||||
defaultChecked={data.settings.vc === option}
|
||||
/>
|
||||
<label htmlFor={option} className="mb-0 text-main-forced">
|
||||
{label(option)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Languages() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [value, setValue] = React.useState(data.settings.languages ?? []);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<input type="hidden" name="languages" value={JSON.stringify(value)} />
|
||||
<label>{t("q:settings.voiceChat.languages.header")}</label>
|
||||
<select
|
||||
className="w-max"
|
||||
onChange={(e) => {
|
||||
const newLanguages = [...value, e.target.value].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
setValue(newLanguages);
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("q:settings.voiceChat.languages.placeholder")}
|
||||
</option>
|
||||
{languagesUnified
|
||||
.filter((lang) => !value.includes(lang.code))
|
||||
.map((option) => {
|
||||
return (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="mt-2">
|
||||
{value.map((code) => {
|
||||
const name = languagesUnified.find((l) => l.code === code)?.name;
|
||||
|
||||
return (
|
||||
<div key={code} className="stack horizontal items-center sm">
|
||||
{name}{" "}
|
||||
<SendouButton
|
||||
icon={<X />}
|
||||
variant="minimal-destructive"
|
||||
onPress={() => {
|
||||
const newLanguages = value.filter(
|
||||
(codeInArr) => codeInArr !== code,
|
||||
);
|
||||
setValue(newLanguages);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeaponPool() {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [weapons, setWeapons] = React.useState(data.settings.qWeaponPool ?? []);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const latestWeapon = weapons[weapons.length - 1]?.weaponSplId ?? null;
|
||||
const defaultWeaponPool = (data.settings.qWeaponPool ?? []).map((w) => ({
|
||||
id: w.weaponSplId,
|
||||
isFavorite: Boolean(w.isFavorite),
|
||||
}));
|
||||
|
||||
return (
|
||||
<details>
|
||||
@@ -393,98 +299,16 @@ function WeaponPool() {
|
||||
<span>{t("q:settings.weaponPool.header")}</span> <Puzzle />
|
||||
</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4 stack items-center">
|
||||
<input
|
||||
type="hidden"
|
||||
name="weaponPool"
|
||||
value={JSON.stringify(weapons)}
|
||||
/>
|
||||
<div className={styles.weaponPoolSelectContainer}>
|
||||
{weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
|
||||
<WeaponSelect
|
||||
onChange={(weaponSplId) => {
|
||||
setWeapons([
|
||||
...weapons,
|
||||
{
|
||||
weaponSplId,
|
||||
isFavorite: 0,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
// empty on selection
|
||||
key={latestWeapon ?? "empty"}
|
||||
disabledWeaponIds={weapons.map((w) => w.weaponSplId)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-info">
|
||||
{t("q:settings.weaponPool.full")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack horizontal md justify-center">
|
||||
{weapons.map((weapon) => {
|
||||
return (
|
||||
<div key={weapon.weaponSplId} className="stack xs">
|
||||
<div>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponSplId}
|
||||
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack sm horizontal items-center justify-center">
|
||||
<SendouButton
|
||||
icon={
|
||||
<Star
|
||||
className={weapon.isFavorite ? styles.starFilled : ""}
|
||||
/>
|
||||
}
|
||||
variant="minimal"
|
||||
aria-label="Favorite weapon"
|
||||
onPress={() =>
|
||||
setWeapons(
|
||||
weapons.map((w) =>
|
||||
w.weaponSplId === weapon.weaponSplId
|
||||
? {
|
||||
...weapon,
|
||||
isFavorite: weapon.isFavorite === 1 ? 0 : 1,
|
||||
}
|
||||
: w,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SendouButton
|
||||
icon={<Trash />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Delete weapon"
|
||||
onPress={() =>
|
||||
setWeapons(
|
||||
weapons.filter(
|
||||
(w) => w.weaponSplId !== weapon.weaponSplId,
|
||||
),
|
||||
)
|
||||
}
|
||||
data-testid={`delete-weapon-${weapon.weaponSplId}`}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<SubmitButton
|
||||
size="big"
|
||||
className="mx-auto"
|
||||
_action="UPDATE_SENDOUQ_WEAPON_POOL"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<div className="mb-4">
|
||||
<SendouForm
|
||||
schema={updateWeaponPoolSchema}
|
||||
defaultValues={{
|
||||
weaponPool: defaultWeaponPool,
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => <FormField name="weaponPool" />}
|
||||
</SendouForm>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -682,40 +506,25 @@ function TrustedUsers() {
|
||||
|
||||
function Misc() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [checked, setChecked] = React.useState(Boolean(data.settings.noScreen));
|
||||
const { t } = useTranslation(["common", "q", "weapons"]);
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className={styles.summary}>
|
||||
<div>{t("q:settings.misc.header")}</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
|
||||
<div className="stack horizontal xs items-center">
|
||||
<SendouSwitch
|
||||
isSelected={checked}
|
||||
onChange={setChecked}
|
||||
id="noScreen"
|
||||
name="noScreen"
|
||||
/>
|
||||
<label className="mb-0" htmlFor="noScreen">
|
||||
{t("q:settings.avoid.label", {
|
||||
special: t("weapons:SPECIAL_19"),
|
||||
})}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<SubmitButton
|
||||
size="big"
|
||||
className="mx-auto"
|
||||
_action="UPDATE_NO_SCREEN"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<div className="mb-4 ml-2-5">
|
||||
<SendouForm
|
||||
schema={updateNoScreenSchema}
|
||||
defaultValues={{
|
||||
newValue: Boolean(data.settings.noScreen),
|
||||
}}
|
||||
action={SETTINGS_PAGE}
|
||||
autoSubmit
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
18
app/features/session-id/session-id-context.server.ts
Normal file
18
app/features/session-id/session-id-context.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
interface SessionIdContext {
|
||||
sessionId: string | undefined;
|
||||
}
|
||||
|
||||
export const sessionIdAsyncLocalStorage =
|
||||
new AsyncLocalStorage<SessionIdContext>();
|
||||
|
||||
function getSessionId(): string | undefined {
|
||||
return sessionIdAsyncLocalStorage.getStore()?.sessionId;
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __getServerSessionId: (() => string | undefined) | undefined;
|
||||
}
|
||||
|
||||
globalThis.__getServerSessionId = getSessionId;
|
||||
17
app/features/session-id/session-id-middleware.server.ts
Normal file
17
app/features/session-id/session-id-middleware.server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { sessionIdAsyncLocalStorage } from "./session-id-context.server";
|
||||
|
||||
type MiddlewareArgs = {
|
||||
request: Request;
|
||||
context: unknown;
|
||||
};
|
||||
|
||||
type MiddlewareFn = (
|
||||
args: MiddlewareArgs,
|
||||
next: () => Promise<Response>,
|
||||
) => Promise<Response>;
|
||||
|
||||
export const sessionIdMiddleware: MiddlewareFn = async ({ request }, next) => {
|
||||
const sessionId = request.headers.get("Sendou-Session-Id") ?? undefined;
|
||||
|
||||
return sessionIdAsyncLocalStorage.run({ sessionId }, () => next());
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
} from "react-router";
|
||||
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { Theme, useTheme } from "~/features/theme/core/provider";
|
||||
import { SelectFormField } from "~/form/fields/SelectFormField";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { languages } from "~/modules/i18n/config";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
@@ -25,6 +26,12 @@ import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SendouPopover } from "../../../components/elements/Popover";
|
||||
import { action } from "../actions/settings.server";
|
||||
import { loader } from "../loaders/settings.server";
|
||||
import {
|
||||
clockFormatSchema,
|
||||
disableBuildAbilitySortingSchema,
|
||||
disallowScrimPickupsFromUntrustedSchema,
|
||||
updateNoScreenSchema,
|
||||
} from "../settings-schemas";
|
||||
import styles from "./settings.module.css";
|
||||
import "./settings.global.css";
|
||||
import type { ThemeInput } from "~/utils/oklch-gamut";
|
||||
@@ -51,7 +58,17 @@ export default function SettingsPage() {
|
||||
{t("common:settings.locales")}
|
||||
</Divider>
|
||||
<LanguageSelector />
|
||||
{user ? <ClockFormatSelector /> : null}
|
||||
{user ? (
|
||||
<SendouForm
|
||||
schema={clockFormatSchema}
|
||||
defaultValues={{
|
||||
newValue: user.preferences.clockFormat ?? "auto",
|
||||
}}
|
||||
autoSubmit
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
) : null}
|
||||
<Divider className={styles.divider} smallText>
|
||||
{t("common:settings.theme")}
|
||||
</Divider>
|
||||
@@ -64,36 +81,35 @@ export default function SettingsPage() {
|
||||
</Divider>
|
||||
<PushNotificationsEnabler />
|
||||
<div className="mt-6 stack md">
|
||||
<PreferenceSelectorSwitch
|
||||
_action="UPDATE_DISABLE_BUILD_ABILITY_SORTING"
|
||||
defaultSelected={
|
||||
user?.preferences.disableBuildAbilitySorting ?? false
|
||||
}
|
||||
label={t(
|
||||
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.label",
|
||||
)}
|
||||
bottomText={t(
|
||||
"common:settings.UPDATE_DISABLE_BUILD_ABILITY_SORTING.bottomText",
|
||||
)}
|
||||
/>
|
||||
<PreferenceSelectorSwitch
|
||||
_action="DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"
|
||||
defaultSelected={
|
||||
user?.preferences.disallowScrimPickupsFromUntrusted ?? false
|
||||
}
|
||||
label={t(
|
||||
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.label",
|
||||
)}
|
||||
bottomText={t(
|
||||
"common:settings.DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED.bottomText",
|
||||
)}
|
||||
/>
|
||||
<PreferenceSelectorSwitch
|
||||
_action="UPDATE_NO_SCREEN"
|
||||
defaultSelected={Boolean(data.noScreen)}
|
||||
label={t("common:settings.UPDATE_NO_SCREEN.label")}
|
||||
bottomText={t("common:settings.UPDATE_NO_SCREEN.bottomText")}
|
||||
/>
|
||||
<SendouForm
|
||||
schema={disableBuildAbilitySortingSchema}
|
||||
defaultValues={{
|
||||
newValue:
|
||||
user.preferences.disableBuildAbilitySorting ?? false,
|
||||
}}
|
||||
autoSubmit
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
<SendouForm
|
||||
schema={disallowScrimPickupsFromUntrustedSchema}
|
||||
defaultValues={{
|
||||
newValue:
|
||||
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
|
||||
}}
|
||||
autoSubmit
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
<SendouForm
|
||||
schema={updateNoScreenSchema}
|
||||
defaultValues={{
|
||||
newValue: Boolean(data.noScreen),
|
||||
}}
|
||||
autoSubmit
|
||||
>
|
||||
{({ FormField }) => <FormField name="newValue" />}
|
||||
</SendouForm>
|
||||
</div>
|
||||
<form method="post" action={LOG_OUT_URL} className="mt-6">
|
||||
<SendouButton
|
||||
@@ -125,28 +141,23 @@ function LanguageSelector() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLanguageChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
const newLang = event.target.value;
|
||||
const languageItems = languages.map((lang) => ({
|
||||
value: lang.code,
|
||||
label: lang.name,
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (newLang: string | null) => {
|
||||
if (!newLang) return;
|
||||
navigate(`?${addUniqueParam(searchParams, "lng", newLang).toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="lang">{t("common:header.language")}</Label>
|
||||
<select
|
||||
id="lang"
|
||||
defaultValue={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<SelectFormField
|
||||
label={t("common:header.language")}
|
||||
items={languageItems}
|
||||
value={i18n.language}
|
||||
onChange={handleLanguageChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,30 +176,25 @@ function ThemeSelector() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { userTheme, setUserTheme } = useTheme();
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
if (!document.startViewTransition) {
|
||||
setUserTheme(event.target.value as Theme);
|
||||
return;
|
||||
}
|
||||
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
|
||||
(theme) => ({
|
||||
value: theme,
|
||||
label: t(`common:theme.${theme}`),
|
||||
}),
|
||||
);
|
||||
|
||||
document.startViewTransition(() =>
|
||||
setUserTheme(event.target.value as Theme),
|
||||
);
|
||||
const handleThemeChange = (newTheme: string | null) => {
|
||||
if (!newTheme) return;
|
||||
setUserTheme(newTheme as Theme);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="theme">{t("common:header.theme")}</Label>
|
||||
<select id="theme" defaultValue={userTheme ?? "auto"} onChange={onChange}>
|
||||
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
|
||||
return (
|
||||
<option key={theme} value={theme}>
|
||||
{t(`common:theme.${theme}`)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<SelectFormField
|
||||
label={t("common:header.theme")}
|
||||
items={themeItems}
|
||||
value={userTheme ?? "auto"}
|
||||
onChange={handleThemeChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,38 +230,6 @@ function CustomColorSelector() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClockFormatSelector() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const handleClockFormatChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
const newFormat = event.target.value as "auto" | "24h" | "12h";
|
||||
fetcher.submit(
|
||||
{ _action: "UPDATE_CLOCK_FORMAT", newValue: newFormat },
|
||||
{ method: "post", encType: "application/json" },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="clock-format">{t("common:settings.clockFormat")}</Label>
|
||||
<select
|
||||
id="clock-format"
|
||||
defaultValue={user?.preferences.clockFormat ?? "auto"}
|
||||
onChange={handleClockFormatChange}
|
||||
disabled={fetcher.state !== "idle"}
|
||||
>
|
||||
<option value="auto">{t("common:clockFormat.auto")}</option>
|
||||
<option value="24h">{t("common:clockFormat.24h")}</option>
|
||||
<option value="12h">{t("common:clockFormat.12h")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
|
||||
function PushNotificationsEnabler() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
@@ -346,38 +320,3 @@ function PushNotificationsEnabler() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferenceSelectorSwitch({
|
||||
_action,
|
||||
label,
|
||||
bottomText,
|
||||
defaultSelected,
|
||||
}: {
|
||||
_action: string;
|
||||
label: string;
|
||||
bottomText: string;
|
||||
defaultSelected: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const onChange = (isSelected: boolean) => {
|
||||
fetcher.submit(
|
||||
{ _action, newValue: isSelected },
|
||||
{ method: "post", encType: "application/json" },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SendouSwitch
|
||||
defaultSelected={defaultSelected}
|
||||
onChange={onChange}
|
||||
isDisabled={fetcher.state !== "idle"}
|
||||
data-testid={`${_action}-switch`}
|
||||
>
|
||||
{label}
|
||||
</SendouSwitch>
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { _action, themeInputSchema } from "~/utils/zod";
|
||||
import { select, stringConstant, toggle } from "~/form/fields";
|
||||
|
||||
export { themeInputSchema };
|
||||
export const clockFormatSchema = z.object({
|
||||
_action: stringConstant("UPDATE_CLOCK_FORMAT"),
|
||||
newValue: select({
|
||||
label: "labels.clockFormat",
|
||||
items: [
|
||||
{ value: "auto", label: "options.clockFormat.auto" },
|
||||
{ value: "24h", label: "options.clockFormat.24h" },
|
||||
{ value: "12h", label: "options.clockFormat.12h" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export const disableBuildAbilitySortingSchema = z.object({
|
||||
_action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
|
||||
newValue: toggle({
|
||||
label: "labels.disableBuildAbilitySorting",
|
||||
bottomText: "bottomTexts.disableBuildAbilitySorting",
|
||||
}),
|
||||
});
|
||||
|
||||
export const disallowScrimPickupsFromUntrustedSchema = z.object({
|
||||
_action: stringConstant("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
|
||||
newValue: toggle({
|
||||
label: "labels.disallowScrimPickupsFromUntrusted",
|
||||
bottomText: "bottomTexts.disallowScrimPickupsFromUntrusted",
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateNoScreenSchema = z.object({
|
||||
_action: stringConstant("UPDATE_NO_SCREEN"),
|
||||
newValue: toggle({
|
||||
label: "labels.noScreen",
|
||||
bottomText: "bottomTexts.noScreen",
|
||||
}),
|
||||
});
|
||||
|
||||
export const settingsEditSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("UPDATE_CUSTOM_THEME"),
|
||||
newValue: themeInputSchema.nullable(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
|
||||
newValue: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
|
||||
newValue: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_NO_SCREEN"),
|
||||
newValue: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_CLOCK_FORMAT"),
|
||||
newValue: z.enum(["auto", "24h", "12h"]),
|
||||
}),
|
||||
disableBuildAbilitySortingSchema,
|
||||
disallowScrimPickupsFromUntrustedSchema,
|
||||
updateNoScreenSchema,
|
||||
clockFormatSchema,
|
||||
]);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
concatUserSubmittedImagePrefix,
|
||||
tournamentLogoOrNull,
|
||||
} from "~/utils/kysely.server";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
|
||||
export function findAllUndisbanded() {
|
||||
return db
|
||||
@@ -266,20 +267,22 @@ export async function teamsByMemberUserId(
|
||||
}
|
||||
|
||||
export async function create(
|
||||
args: Pick<Insertable<Tables["Team"]>, "name" | "customUrl"> & {
|
||||
args: Pick<Insertable<Tables["Team"]>, "name"> & {
|
||||
ownerUserId: number;
|
||||
isMainTeam: boolean;
|
||||
},
|
||||
) {
|
||||
const customUrl = mySlugify(args.name);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const team = await trx
|
||||
.insertInto("AllTeam")
|
||||
.values({
|
||||
name: args.name,
|
||||
customUrl: args.customUrl,
|
||||
customUrl,
|
||||
inviteCode: shortNanoid(),
|
||||
})
|
||||
.returning("id")
|
||||
.returning(["id", "customUrl"])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
@@ -291,22 +294,25 @@ export async function create(
|
||||
isMainTeam: Number(args.isMainTeam),
|
||||
})
|
||||
.execute();
|
||||
|
||||
return team;
|
||||
});
|
||||
}
|
||||
|
||||
export async function update({
|
||||
id,
|
||||
name,
|
||||
customUrl,
|
||||
bio,
|
||||
bsky,
|
||||
tag,
|
||||
customTheme,
|
||||
}: Pick<
|
||||
Insertable<Tables["Team"]>,
|
||||
"id" | "name" | "customUrl" | "bio" | "bsky" | "tag"
|
||||
"id" | "name" | "bio" | "bsky" | "tag"
|
||||
> & { customTheme: CustomTheme | null }) {
|
||||
return db
|
||||
const customUrl = mySlugify(name);
|
||||
|
||||
const team = await db
|
||||
.updateTable("AllTeam")
|
||||
.set({
|
||||
name,
|
||||
@@ -319,6 +325,8 @@ export async function update({
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
export function switchMainTeam({
|
||||
|
||||
79
app/features/team/actions/t.$customUrl.edit.server.test.ts
Normal file
79
app/features/team/actions/t.$customUrl.edit.server.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
dbInsertUsers,
|
||||
dbReset,
|
||||
wrappedAction,
|
||||
} from "~/utils/Test";
|
||||
import { action as teamIndexPageAction } from "../actions/t.server";
|
||||
import type { createTeamSchema } from "../team-schemas";
|
||||
import type { editTeamSchema } from "../team-schemas.server";
|
||||
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
|
||||
|
||||
const createTeamAction = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
|
||||
action: _editTeamProfileAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
_action: "EDIT",
|
||||
name: "Team 1",
|
||||
bio: "",
|
||||
bsky: "",
|
||||
tag: "",
|
||||
} as const;
|
||||
|
||||
describe("team page editing", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers();
|
||||
await createTeamAction({ name: "Team 1" }, { user: "regular" });
|
||||
});
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
it("adds valid custom css vars", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
css: JSON.stringify({ bg: "#fff" }),
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
});
|
||||
|
||||
it("prevents adding custom css var of unknown property", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
css: JSON.stringify({
|
||||
"backdrop-filter": "#fff",
|
||||
}),
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
assertResponseErrored(response);
|
||||
});
|
||||
|
||||
it("prevents adding custom css var of unknown value", async () => {
|
||||
const response = await editTeamProfileAction(
|
||||
{
|
||||
css: JSON.stringify({
|
||||
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
|
||||
}),
|
||||
...DEFAULT_FIELDS,
|
||||
},
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
assertResponseErrored(response);
|
||||
});
|
||||
});
|
||||
@@ -55,18 +55,19 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
}
|
||||
case "EDIT": {
|
||||
const newCustomUrl = mySlugify(data.name);
|
||||
const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
|
||||
|
||||
errorToastIfFalsy(
|
||||
newCustomUrl.length > 0,
|
||||
"Team name can't be only special characters",
|
||||
);
|
||||
|
||||
// can't take someone else's custom url
|
||||
if (existingTeam && existingTeam.id !== team.id) {
|
||||
return {
|
||||
errors: ["forms.errors.duplicateName"],
|
||||
};
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const duplicateTeam = teams.find(
|
||||
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
|
||||
);
|
||||
|
||||
if (duplicateTeam) {
|
||||
return { errors: ["forms:errors.duplicateName"] };
|
||||
}
|
||||
|
||||
const customTheme =
|
||||
@@ -74,14 +75,13 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
? clampThemeToGamut(data.customTheme)
|
||||
: null;
|
||||
|
||||
const editedTeam = await TeamRepository.update({
|
||||
const updatedTeam = await TeamRepository.update({
|
||||
id: team.id,
|
||||
customUrl: newCustomUrl,
|
||||
...data,
|
||||
customTheme,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(editedTeam.customUrl));
|
||||
throw redirect(teamPage(updatedTeam.customUrl));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertResponseErrored,
|
||||
dbInsertUsers,
|
||||
dbReset,
|
||||
wrappedAction,
|
||||
} from "~/utils/Test";
|
||||
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
|
||||
import { action as teamIndexPageAction } from "../actions/t.server";
|
||||
import type { createTeamSchema } from "../team-schemas.server";
|
||||
import type { createTeamSchema } from "../team-schemas";
|
||||
|
||||
const action = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
describe("team creation", () => {
|
||||
@@ -24,12 +20,12 @@ describe("team creation", () => {
|
||||
await action({ name: "Team 1" }, { user: "regular" });
|
||||
const res = await action({ name: "Team 1" }, { user: "regular" });
|
||||
|
||||
expect(res.errors[0]).toBe("forms.errors.duplicateName");
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
|
||||
});
|
||||
|
||||
it("prevents creating a team whose name is only special characters", async () => {
|
||||
const response = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
|
||||
const res = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
|
||||
|
||||
assertResponseErrored(response);
|
||||
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { mySlugify, teamPage } from "~/utils/urls";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { TEAM } from "../team-constants";
|
||||
import { createTeamSchema } from "../team-schemas.server";
|
||||
import { createTeamSchemaServer } from "../team-schemas.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: createTeamSchema,
|
||||
schema: createTeamSchemaServer,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
|
||||
const currentTeamCount = teams.filter((team) =>
|
||||
@@ -28,26 +35,11 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
"Already in max amount of teams",
|
||||
);
|
||||
|
||||
// two teams can't have same customUrl
|
||||
const customUrl = mySlugify(data.name);
|
||||
|
||||
errorToastIfFalsy(
|
||||
customUrl.length > 0,
|
||||
"Team name can't be only special characters",
|
||||
);
|
||||
|
||||
if (teams.some((team) => team.customUrl === customUrl)) {
|
||||
return {
|
||||
errors: ["forms.errors.duplicateName"],
|
||||
};
|
||||
}
|
||||
|
||||
await TeamRepository.create({
|
||||
const team = await TeamRepository.create({
|
||||
ownerUserId: user.id,
|
||||
name: data.name,
|
||||
customUrl,
|
||||
isMainTeam: currentTeamCount === 0,
|
||||
});
|
||||
|
||||
throw redirect(teamPage(customUrl));
|
||||
throw redirect(teamPage(team.customUrl));
|
||||
};
|
||||
|
||||
@@ -7,14 +7,17 @@ import {
|
||||
} from "~/utils/Test";
|
||||
import { action as teamIndexPageAction } from "../actions/t.server";
|
||||
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
|
||||
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
|
||||
import type { createTeamSchema } from "../team-schemas";
|
||||
import type { editTeamSchema } from "../team-schemas.server";
|
||||
|
||||
const createTeamAction = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const editTeamAction = wrappedAction<typeof editTeamSchema>({
|
||||
action: _editTeamAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const DEFAULT_FIELDS = {
|
||||
@@ -42,7 +45,7 @@ describe("team creation", () => {
|
||||
{ user: "regular", params: { customUrl: "team-1" } },
|
||||
);
|
||||
|
||||
expect(res.errors[0]).toBe("forms.errors.duplicateName");
|
||||
expect(res.errors[0]).toBe("forms:errors.duplicateName");
|
||||
});
|
||||
|
||||
it("prevents editing team name to only special characters", async () => {
|
||||
|
||||
@@ -14,8 +14,8 @@ import { action as _teamPageAction } from "../actions/t.$customUrl.index.server"
|
||||
import { action as teamIndexPageAction } from "../actions/t.server";
|
||||
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import type { createTeamSchema } from "../team-schemas";
|
||||
import type {
|
||||
createTeamSchema,
|
||||
editTeamSchema,
|
||||
teamProfilePageActionSchema,
|
||||
} from "../team-schemas.server";
|
||||
@@ -28,12 +28,15 @@ const loadUserTeamLoader = wrappedLoader<
|
||||
|
||||
const createTeamAction = wrappedAction<typeof createTeamSchema>({
|
||||
action: teamIndexPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
|
||||
action: _teamPageAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
const editTeamAction = wrappedAction<typeof editTeamSchema>({
|
||||
action: _editTeamAction,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
async function loadTeams() {
|
||||
|
||||
@@ -2,16 +2,15 @@ import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Form, Link, useLoaderData, useSearchParams } from "react-router";
|
||||
import { Link, useLoaderData, useSearchParams } from "react-router";
|
||||
import { AddNewButton } from "~/components/AddNewButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { SendouForm } from "~/form";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
import { action } from "../actions/t.server";
|
||||
import { loader } from "../loaders/t.server";
|
||||
import { TEAM, TEAMS_PER_PAGE } from "../team-constants";
|
||||
import { createTeamSchema } from "../team-schemas";
|
||||
export { loader, action };
|
||||
|
||||
import styles from "../team.module.css";
|
||||
@@ -40,7 +40,7 @@ export const meta: MetaFunction = (args) => {
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
i18n: ["team", "forms"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("t"),
|
||||
href: TEAM_SEARCH_PAGE,
|
||||
@@ -193,23 +193,9 @@ function NewTeamDialog() {
|
||||
isOpen={isOpen}
|
||||
onCloseTo={TEAM_SEARCH_PAGE}
|
||||
>
|
||||
<Form method="post" className="stack md">
|
||||
<div className="">
|
||||
<label htmlFor="name">{t("common:forms.name")}</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
minLength={TEAM.NAME_MIN_LENGTH}
|
||||
maxLength={TEAM.NAME_MAX_LENGTH}
|
||||
required
|
||||
data-testid={isOpen ? "new-team-name-input" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<FormErrors namespace="team" />
|
||||
<div className="mt-2">
|
||||
<SubmitButton>{t("common:actions.create")}</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
<SendouForm schema={createTeamSchema}>
|
||||
{({ FormField }) => <FormField name="name" />}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import {
|
||||
_action,
|
||||
falsyToNull,
|
||||
@@ -6,17 +7,25 @@ import {
|
||||
safeStringSchema,
|
||||
themeInputSchema,
|
||||
} from "~/utils/zod";
|
||||
import * as TeamRepository from "./TeamRepository.server";
|
||||
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
|
||||
import { createTeamSchema } from "./team-schemas";
|
||||
|
||||
export const createTeamSchemaServer = z.object({
|
||||
...createTeamSchema.shape,
|
||||
name: createTeamSchema.shape.name.refine(
|
||||
async (name) => {
|
||||
const teams = await TeamRepository.findAllUndisbanded();
|
||||
const customUrl = mySlugify(name);
|
||||
|
||||
return !teams.some((team) => team.customUrl === customUrl);
|
||||
},
|
||||
{ message: "forms:errors.duplicateName" },
|
||||
),
|
||||
});
|
||||
|
||||
export const teamParamsSchema = z.object({ customUrl: z.string() });
|
||||
|
||||
export const createTeamSchema = z.object({
|
||||
name: safeStringSchema({
|
||||
min: TEAM.NAME_MIN_LENGTH,
|
||||
max: TEAM.NAME_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const teamProfilePageActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("LEAVE_TEAM"),
|
||||
|
||||
17
app/features/team/team-schemas.ts
Normal file
17
app/features/team/team-schemas.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { textFieldRequired } from "~/form/fields";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import { TEAM } from "./team-constants";
|
||||
|
||||
export const createTeamSchema = z.object({
|
||||
name: textFieldRequired({
|
||||
label: "labels.name",
|
||||
minLength: TEAM.NAME_MIN_LENGTH,
|
||||
maxLength: TEAM.NAME_MAX_LENGTH,
|
||||
validate: {
|
||||
func: (teamName) =>
|
||||
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
|
||||
message: "forms:errors.noOnlySpecialCharacters",
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -54,7 +54,8 @@
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(255, 255, 255, 0),
|
||||
rgba(0, 0, 0, 0.6)
|
||||
), var(--team-banner-img);
|
||||
),
|
||||
var(--team-banner-img);
|
||||
background-size: cover;
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 1;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user