mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Tournament organization page (#1811)
* Initial * Calendar initial * Extract EventCalendar * Events list initial * Winners * SQL fixes * List events by series * Leaderboards * Series leaderboard * Own entry peek * Edit page skeleton * RHF initial test * RHF stuff * Form etc. progress * Fix tournament series description * Fix tabs layout * Fix socials insert * Check for not removing admin * Adding series * TODOs * Allow updating org with no series * FormFieldset * Allow series without events * TextAreaFormfield accepting array syntax * Input form array field * ToggleFormField * SelectFormField * UserSearchFormField * Fetch badgeOptions * Badge editing * Progress * Use native preventScrollReset * Rename func * Fix sticky scroll * Fix translation * i18n errors * handle,meta in edit * Add ref to user search * TODOs * Done
This commit is contained in:
parent
7c1c0544fb
commit
9312fad90f
|
|
@ -67,7 +67,9 @@ type LinkButtonProps = Pick<
|
|||
ButtonProps,
|
||||
"variant" | "children" | "className" | "size" | "testId" | "icon"
|
||||
> &
|
||||
Pick<LinkProps, "to" | "prefetch" | "state"> & { "data-cy"?: string } & {
|
||||
Pick<LinkProps, "to" | "prefetch" | "preventScrollReset"> & {
|
||||
"data-cy"?: string;
|
||||
} & {
|
||||
isExternal?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -79,9 +81,9 @@ export function LinkButton({
|
|||
to,
|
||||
prefetch,
|
||||
isExternal,
|
||||
state,
|
||||
testId,
|
||||
icon,
|
||||
preventScrollReset,
|
||||
}: LinkButtonProps) {
|
||||
if (isExternal) {
|
||||
return (
|
||||
|
|
@ -119,7 +121,7 @@ export function LinkButton({
|
|||
to={to}
|
||||
data-testid={testId}
|
||||
prefetch={prefetch}
|
||||
state={state}
|
||||
preventScrollReset={preventScrollReset}
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
// temporary workaround before Remix has React Router 6.4
|
||||
// https://github.com/remix-run/remix/issues/186#issuecomment-1178395835
|
||||
|
||||
import { ScrollRestoration, useLocation } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
|
||||
export function ConditionalScrollRestoration() {
|
||||
const isFirstRenderRef = React.useRef(true);
|
||||
const location = useLocation();
|
||||
|
||||
React.useEffect(() => {
|
||||
isFirstRenderRef.current = false;
|
||||
}, []);
|
||||
|
||||
if (
|
||||
!isFirstRenderRef.current &&
|
||||
location.state != null &&
|
||||
typeof location.state === "object" &&
|
||||
(location.state as { scroll: boolean }).scroll === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <ScrollRestoration getKey={(location) => location.pathname} />;
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ export function Label({
|
|||
}
|
||||
|
||||
function lengthWarning(valueLimits: NonNullable<LabelProps["valueLimits"]>) {
|
||||
if (valueLimits.current >= valueLimits.max) return "error";
|
||||
if (valueLimits.current > valueLimits.max) return "error";
|
||||
if (valueLimits.current / valueLimits.max >= 0.9) return "warning";
|
||||
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface NewTabsProps {
|
|||
label: string;
|
||||
number?: number;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
content: {
|
||||
key: string;
|
||||
|
|
@ -57,6 +58,7 @@ export function NewTabs(args: NewTabsProps) {
|
|||
key={tab.label}
|
||||
className="tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
disabled={tab.disabled}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
|
|
|
|||
|
|
@ -9,130 +9,140 @@ import { Avatar } from "./Avatar";
|
|||
|
||||
type UserSearchUserItem = NonNullable<UserSearchLoaderData>["users"][number];
|
||||
|
||||
export function UserSearch({
|
||||
inputName,
|
||||
onChange,
|
||||
initialUserId,
|
||||
id,
|
||||
className,
|
||||
userIdsToOmit,
|
||||
required,
|
||||
}: {
|
||||
inputName: string;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
initialUserId?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
userIdsToOmit?: Set<number>;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUser, setSelectedUser] =
|
||||
React.useState<UserSearchUserItem | null>(null);
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const [query, setQuery] = React.useState("");
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!query) return;
|
||||
|
||||
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||
export const UserSearch = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
{
|
||||
inputName?: string;
|
||||
onChange?: (user: UserSearchUserItem) => void;
|
||||
initialUserId?: number;
|
||||
id?: string;
|
||||
className?: string;
|
||||
userIdsToOmit?: Set<number>;
|
||||
required?: boolean;
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
inputName,
|
||||
onChange,
|
||||
initialUserId,
|
||||
id,
|
||||
className,
|
||||
userIdsToOmit,
|
||||
required,
|
||||
onBlur,
|
||||
},
|
||||
1000,
|
||||
[query],
|
||||
);
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedUser, setSelectedUser] =
|
||||
React.useState<UserSearchUserItem | null>(null);
|
||||
const queryFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const initialUserFetcher = useFetcher<UserSearchLoaderData>();
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
// load initial user
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
useDebounce(
|
||||
() => {
|
||||
if (!query) return;
|
||||
queryFetcher.load(`/u?q=${query}&limit=6`);
|
||||
},
|
||||
1000,
|
||||
[query],
|
||||
);
|
||||
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
React.useEffect(() => {
|
||||
if (!initialUserFetcher.data) return;
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!initialUserId ||
|
||||
initialUserFetcher.state !== "idle" ||
|
||||
initialUserFetcher.data
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initialUserFetcher.load(`/u?q=${initialUserId}`);
|
||||
}, [initialUserId, initialUserFetcher]);
|
||||
|
||||
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||
}, [initialUserFetcher.data]);
|
||||
React.useEffect(() => {
|
||||
if (!initialUserFetcher.data) return;
|
||||
setSelectedUser(initialUserFetcher.data.users[0]);
|
||||
}, [initialUserFetcher.data]);
|
||||
|
||||
const allUsers = queryFetcher.data?.users ?? [];
|
||||
const allUsers = queryFetcher.data?.users ?? [];
|
||||
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||
const noMatches = queryFetcher.data && users.length === 0;
|
||||
const initialSelectionIsLoading = Boolean(
|
||||
initialUserId && !initialUserFetcher.data,
|
||||
);
|
||||
|
||||
const users = allUsers.filter((u) => !userIdsToOmit?.has(u.id));
|
||||
const noMatches = queryFetcher.data && users.length === 0;
|
||||
|
||||
const initialSelectionIsLoading = Boolean(
|
||||
initialUserId && !initialUserFetcher.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
{selectedUser && inputName ? (
|
||||
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||
) : null}
|
||||
<Combobox
|
||||
value={selectedUser}
|
||||
onChange={(newUser) => {
|
||||
setSelectedUser(newUser);
|
||||
onChange?.(newUser!);
|
||||
}}
|
||||
disabled={initialSelectionIsLoading}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={
|
||||
initialSelectionIsLoading
|
||||
? t("actions.loading")
|
||||
: "Search via name or ID..."
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(user: UserSearchUserItem) => user?.username ?? ""}
|
||||
className={clsx("combobox-input", className)}
|
||||
data-1p-ignore
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
/>
|
||||
<Combobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !queryFetcher.data,
|
||||
})}
|
||||
return (
|
||||
<div className="combobox-wrapper">
|
||||
{selectedUser && inputName ? (
|
||||
<input type="hidden" name={inputName} value={selectedUser.id} />
|
||||
) : null}
|
||||
<Combobox
|
||||
value={selectedUser}
|
||||
onChange={(newUser) => {
|
||||
setSelectedUser(newUser);
|
||||
onChange?.(newUser!);
|
||||
}}
|
||||
disabled={initialSelectionIsLoading}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : null}
|
||||
{users.map((user, i) => (
|
||||
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||
{({ active }) => (
|
||||
<li
|
||||
className={clsx("combobox-item", { active })}
|
||||
data-testid={`combobox-option-${i}`}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
<div>
|
||||
<div className="stack xs horizontal items-center">
|
||||
<span className="combobox-username">{user.username}</span>{" "}
|
||||
{user.plusTier ? (
|
||||
<span className="text-xxs">+{user.plusTier}</span>
|
||||
<Combobox.Input
|
||||
ref={ref}
|
||||
placeholder={
|
||||
initialSelectionIsLoading
|
||||
? t("actions.loading")
|
||||
: "Search via name or ID..."
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
displayValue={(user: UserSearchUserItem) => user?.username ?? ""}
|
||||
className={clsx("combobox-input", className)}
|
||||
data-1p-ignore
|
||||
data-testid={`${inputName}-combobox-input`}
|
||||
id={id}
|
||||
required={required}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Combobox.Options
|
||||
className={clsx("combobox-options", {
|
||||
empty: noMatches,
|
||||
hidden: !queryFetcher.data,
|
||||
})}
|
||||
>
|
||||
{noMatches ? (
|
||||
<div className="combobox-no-matches">
|
||||
{t("forms.errors.noSearchMatches")}{" "}
|
||||
<span className="combobox-emoji">🤔</span>
|
||||
</div>
|
||||
) : null}
|
||||
{users.map((user, i) => (
|
||||
<Combobox.Option key={user.id} value={user} as={React.Fragment}>
|
||||
{({ active }) => (
|
||||
<li
|
||||
className={clsx("combobox-item", { active })}
|
||||
data-testid={`combobox-option-${i}`}
|
||||
>
|
||||
<Avatar user={user} size="xs" />
|
||||
<div>
|
||||
<div className="stack xs horizontal items-center">
|
||||
<span className="combobox-username">
|
||||
{user.username}
|
||||
</span>{" "}
|
||||
{user.plusTier ? (
|
||||
<span className="text-xxs">+{user.plusTier}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{user.discordUniqueName ? (
|
||||
<div className="text-xs">{user.discordUniqueName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{user.discordUniqueName ? (
|
||||
<div className="text-xs">{user.discordUniqueName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</li>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
24
app/components/form/AddFieldButton.tsx
Normal file
24
app/components/form/AddFieldButton.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "../Button";
|
||||
import { PlusIcon } from "../icons/Plus";
|
||||
|
||||
export function AddFieldButton({
|
||||
onClick,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
aria-label="Add form field"
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
onClick={onClick}
|
||||
className="self-start"
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
21
app/components/form/FormFieldset.tsx
Normal file
21
app/components/form/FormFieldset.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
56
app/components/form/MyForm.tsx
Normal file
56
app/components/form/MyForm.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { z } from "zod";
|
||||
import type { ActionError } from "~/utils/remix";
|
||||
import { SubmitButton } from "../SubmitButton";
|
||||
|
||||
export function MyForm<T extends z.ZodTypeAny>({
|
||||
schema,
|
||||
defaultValues,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
schema: T;
|
||||
defaultValues?: z.infer<T>;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const fetcher = useFetcher<any>();
|
||||
const methods = useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
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, { method: "post", encType: "application/json" }),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<fetcher.Form className="stack md-plus items-start" onSubmit={onSubmit}>
|
||||
{title ? <h1 className="text-lg">{title}</h1> : null}
|
||||
{children}
|
||||
<SubmitButton state={fetcher.state} className="mt-6">
|
||||
{t("common:actions.submit")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
14
app/components/form/RemoveFieldButton.tsx
Normal file
14
app/components/form/RemoveFieldButton.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Button } from "../Button";
|
||||
import { TrashIcon } from "../icons/Trash";
|
||||
|
||||
export function RemoveFieldButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
icon={<TrashIcon />}
|
||||
aria-label="Remove form field"
|
||||
size="tiny"
|
||||
variant="minimal-destructive"
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
app/components/form/SelectFormField.tsx
Normal file
45
app/components/form/SelectFormField.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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,
|
||||
}: {
|
||||
label: string;
|
||||
name: FieldPath<T>;
|
||||
values: Array<{ value: string; label: string }>;
|
||||
bottomText?: string;
|
||||
}) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<select {...methods.register(name)}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
46
app/components/form/TextAreaFormField.tsx
Normal file
46
app/components/form/TextAreaFormField.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
72
app/components/form/TextArrayFormField.tsx
Normal file
72
app/components/form/TextArrayFormField.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { z } from "zod";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { AddFieldButton } from "./AddFieldButton";
|
||||
import { RemoveFieldButton } from "./RemoveFieldButton";
|
||||
|
||||
export function TextArrayFormField<T extends z.ZodTypeAny>({
|
||||
label,
|
||||
name,
|
||||
defaultFieldValue,
|
||||
bottomText,
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof z.infer<T> & string;
|
||||
defaultFieldValue: string;
|
||||
bottomText?: string;
|
||||
}) {
|
||||
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(`${name}.${index}.value`)} />
|
||||
{fields.length > 1 ? (
|
||||
<RemoveFieldButton
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
clearErrors(`${name}.root`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{error && (
|
||||
<FormMessage type="error">
|
||||
{error.message as string}
|
||||
</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<AddFieldButton
|
||||
// @ts-expect-error
|
||||
onClick={() => append({ value: defaultFieldValue })}
|
||||
/>
|
||||
{rootError && (
|
||||
<FormMessage type="error">{rootError.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !rootError ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
app/components/form/TextFormField.tsx
Normal file
33
app/components/form/TextFormField.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import * as React from "react";
|
||||
import {
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
get,
|
||||
useFormContext,
|
||||
} from "react-hook-form";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
|
||||
export function TextFormField<T extends FieldValues>({
|
||||
label,
|
||||
name,
|
||||
bottomText,
|
||||
}: { label: string; name: FieldPath<T>; bottomText?: string }) {
|
||||
const methods = useFormContext();
|
||||
const id = React.useId();
|
||||
|
||||
const error = get(methods.formState.errors, name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<input id={id} {...methods.register(name)} />
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
app/components/form/ToggleFormField.tsx
Normal file
41
app/components/form/ToggleFormField.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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 { Toggle } from "../Toggle";
|
||||
|
||||
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: { onChange, value } }) => (
|
||||
<Toggle checked={value} setChecked={onChange} />
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
app/components/form/UserSearchFormField.tsx
Normal file
46
app/components/form/UserSearchFormField.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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 { UserSearch } from "../UserSearch";
|
||||
|
||||
export function UserSearchFormField<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: { onChange, onBlur, value, ref } }) => (
|
||||
<UserSearch
|
||||
onChange={(newUser) => onChange(newUser.id)}
|
||||
initialUserId={value}
|
||||
onBlur={onBlur}
|
||||
ref={ref}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{error && (
|
||||
<FormMessage type="error">{error.message as string}</FormMessage>
|
||||
)}
|
||||
{bottomText && !error ? (
|
||||
<FormMessage type="info">{bottomText}</FormMessage>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -145,6 +145,7 @@ export interface CalendarEvent {
|
|||
participantCount: number | null;
|
||||
tags: string | null;
|
||||
tournamentId: number | null;
|
||||
organizationId: number | null;
|
||||
avatarImgId: number | null;
|
||||
avatarMetadata: ColumnType<
|
||||
CalendarEventAvatarMetadata | null,
|
||||
|
|
@ -628,6 +629,45 @@ export interface TournamentTeamMember {
|
|||
userId: number;
|
||||
}
|
||||
|
||||
export interface TournamentOrganization {
|
||||
id: GeneratedAlways<number>;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
socials: ColumnType<string[] | null, string | null, string | null>;
|
||||
avatarImgId: number | null;
|
||||
}
|
||||
|
||||
export const TOURNAMENT_ORGANIZATION_ROLES = [
|
||||
"ADMIN",
|
||||
"MEMBER",
|
||||
"ORGANIZER",
|
||||
"STREAMER",
|
||||
] as const;
|
||||
type TournamentOrganizationRole =
|
||||
(typeof TOURNAMENT_ORGANIZATION_ROLES)[number];
|
||||
|
||||
export interface TournamentOrganizationMember {
|
||||
organizationId: number;
|
||||
userId: number;
|
||||
role: TournamentOrganizationRole;
|
||||
roleDisplayName: string | null;
|
||||
}
|
||||
|
||||
export interface TournamentOrganizationBadge {
|
||||
organizationId: number;
|
||||
badgeId: number;
|
||||
}
|
||||
|
||||
export interface TournamentOrganizationSeries {
|
||||
id: GeneratedAlways<number>;
|
||||
organizationId: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
substringMatches: ColumnType<string[], string, string>;
|
||||
showLeaderboard: Generated<number>;
|
||||
}
|
||||
|
||||
export interface TrustRelationship {
|
||||
trustGiverUserId: number;
|
||||
trustReceiverUserId: number;
|
||||
|
|
@ -853,6 +893,10 @@ export interface DB {
|
|||
TournamentTeam: TournamentTeam;
|
||||
TournamentTeamCheckIn: TournamentTeamCheckIn;
|
||||
TournamentTeamMember: TournamentTeamMember;
|
||||
TournamentOrganization: TournamentOrganization;
|
||||
TournamentOrganizationMember: TournamentOrganizationMember;
|
||||
TournamentOrganizationBadge: TournamentOrganizationBadge;
|
||||
TournamentOrganizationSeries: TournamentOrganizationSeries;
|
||||
TrustRelationship: TrustRelationship;
|
||||
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
|
||||
UnvalidatedVideo: UnvalidatedVideo;
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import { requireUserId } from "~/features/auth/core/user.server";
|
|||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||
import { isAdmin, isMod } from "~/permissions";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { _action, actualNumber } from "~/utils/zod";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { seed } from "~/db/seed";
|
||||
import { parseRequestFormData } from "~/utils/remix";
|
||||
import { parseRequestPayload } from "~/utils/remix";
|
||||
|
||||
const seedSchema = z.object({
|
||||
variation: z
|
||||
|
|
@ -18,7 +18,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
const { variation } = await parseRequestFormData({
|
||||
const { variation } = await parseRequestPayload({
|
||||
request,
|
||||
schema: seedSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
type SendouRouteHandle,
|
||||
parseFormData,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import {
|
||||
|
|
@ -70,7 +70,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
401,
|
||||
);
|
||||
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editArtSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,6 +63,16 @@ export async function findByOwnerId({
|
|||
});
|
||||
}
|
||||
|
||||
export function findByManagersList(userIds: number[]) {
|
||||
return db
|
||||
.selectFrom("Badge")
|
||||
.select(["Badge.id", "Badge.code", "Badge.displayName", "Badge.hue"])
|
||||
.innerJoin("BadgeManager", "Badge.id", "BadgeManager.badgeId")
|
||||
.where("BadgeManager.userId", "in", userIds)
|
||||
.orderBy("Badge.id asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function findManagedByUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("BadgeManager")
|
||||
|
|
|
|||
24
app/features/badges/badges-utils.ts
Normal file
24
app/features/badges/badges-utils.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { TFunction } from "i18next";
|
||||
import { SPLATOON_3_XP_BADGE_VALUES } from "~/constants";
|
||||
import type { Badge as BadgeDBType } from "~/db/types";
|
||||
|
||||
export function badgeExplanationText(
|
||||
t: TFunction<"badges", undefined>,
|
||||
badge: Pick<BadgeDBType, "displayName" | "code"> & { count?: number },
|
||||
) {
|
||||
if (badge.code === "patreon") return t("patreon");
|
||||
if (badge.code === "patreon_plus") {
|
||||
return t("patreon+");
|
||||
}
|
||||
if (
|
||||
badge.code.startsWith("xp") ||
|
||||
SPLATOON_3_XP_BADGE_VALUES.includes(Number(badge.code) as any)
|
||||
) {
|
||||
return t("xp", { xpText: badge.displayName });
|
||||
}
|
||||
|
||||
return t("tournament", {
|
||||
count: badge.count ?? 1,
|
||||
tournament: badge.displayName,
|
||||
}).replace("'", "'");
|
||||
}
|
||||
80
app/features/badges/components/BadgeDisplay.tsx
Normal file
80
app/features/badges/components/BadgeDisplay.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { Button } from "~/components/Button";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { badgeExplanationText } from "../badges-utils";
|
||||
|
||||
interface BadgeDisplayProps {
|
||||
badges: Array<Tables["Badge"] & { count?: number }>;
|
||||
onBadgeRemove?: (badgeId: number) => void;
|
||||
}
|
||||
|
||||
export function BadgeDisplay({
|
||||
badges: _badges,
|
||||
onBadgeRemove,
|
||||
}: BadgeDisplayProps) {
|
||||
const { t } = useTranslation("badges");
|
||||
const [badges, setBadges] = React.useState(_badges);
|
||||
|
||||
const [bigBadge, ...smallBadges] = badges;
|
||||
if (!bigBadge) return null;
|
||||
|
||||
const setBadgeFirst = (badge: Unpacked<BadgeDisplayProps["badges"]>) => {
|
||||
setBadges(
|
||||
badges.map((b, i) => {
|
||||
if (i === 0) return badge;
|
||||
if (b.code === badge.code) return badges[0];
|
||||
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={clsx("badge-display__badges", {
|
||||
"justify-center": smallBadges.length === 0,
|
||||
})}
|
||||
>
|
||||
<Badge badge={bigBadge} size={125} isAnimated />
|
||||
{smallBadges.length > 0 ? (
|
||||
<div className="badge-display__small-badges">
|
||||
{smallBadges.map((badge) => (
|
||||
<div
|
||||
key={badge.id}
|
||||
className="badge-display__small-badge-container"
|
||||
>
|
||||
<Badge
|
||||
badge={badge}
|
||||
onClick={() => setBadgeFirst(badge)}
|
||||
size={48}
|
||||
isAnimated
|
||||
/>
|
||||
{badge.count && badge.count > 1 ? (
|
||||
<div className="badge-display__small-badge-count">
|
||||
×{badge.count}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="badge-display__badge-explanation">
|
||||
{badgeExplanationText(t, bigBadge)}
|
||||
{onBadgeRemove ? (
|
||||
<Button
|
||||
icon={<TrashIcon />}
|
||||
variant="minimal-destructive"
|
||||
onClick={() => onBadgeRemove(bigBadge.id)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgePage } from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
|
|
@ -22,7 +22,7 @@ import { editBadgeActionSchema } from "../badges-schemas.server";
|
|||
import type { BadgeDetailsContext, BadgeDetailsLoaderData } from "./badges.$id";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editBadgeActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
|
||||
import { Outlet, useLoaderData, useMatches, useParams } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Redirect } from "~/components/Redirect";
|
||||
import { SPLATOON_3_XP_BADGE_VALUES } from "~/constants";
|
||||
import type { Badge as BadgeDBType } from "~/db/types";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { canEditBadgeOwners, isMod } from "~/permissions";
|
||||
import { BADGES_PAGE } from "~/utils/urls";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
import { badgeExplanationText } from "../badges-utils";
|
||||
import type { BadgesLoaderData } from "./badges";
|
||||
|
||||
export interface BadgeDetailsContext {
|
||||
|
|
@ -86,24 +84,3 @@ export default function BadgeDetailsPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function badgeExplanationText(
|
||||
t: TFunction<"badges", undefined>,
|
||||
badge: Pick<BadgeDBType, "displayName" | "code"> & { count?: number },
|
||||
) {
|
||||
if (badge.code === "patreon") return t("patreon");
|
||||
if (badge.code === "patreon_plus") {
|
||||
return t("patreon+");
|
||||
}
|
||||
if (
|
||||
badge.code.startsWith("xp") ||
|
||||
SPLATOON_3_XP_BADGE_VALUES.includes(Number(badge.code) as any)
|
||||
) {
|
||||
return t("xp", { xpText: badge.displayName });
|
||||
}
|
||||
|
||||
return t("tournament", {
|
||||
count: badge.count ?? 1,
|
||||
tournament: badge.displayName,
|
||||
}).replace("'", "'");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function useAnalyzeBuild() {
|
|||
effect: newEffects,
|
||||
focused: String(newFocused),
|
||||
},
|
||||
{ replace: true, state: { scroll: false } },
|
||||
{ replace: true, preventScrollReset: true },
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ export default function WeaponsBuildsPage() {
|
|||
className="m-0-auto"
|
||||
size="tiny"
|
||||
to={loadMoreLink()}
|
||||
state={{ scroll: false }}
|
||||
preventScrollReset
|
||||
>
|
||||
{t("common:actions.loadMore")}
|
||||
</LinkButton>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ExpressionBuilder, Transaction } from "kysely";
|
||||
import type { Expression, ExpressionBuilder, Transaction } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
CalendarEventAvatarMetadata,
|
||||
|
|
@ -57,6 +57,25 @@ const withBadgePrizes = (eb: ExpressionBuilder<DB, "CalendarEvent">) => {
|
|||
).as("badgePrizes");
|
||||
};
|
||||
|
||||
function tournamentOrganization(organizationId: Expression<number | null>) {
|
||||
return jsonObjectFrom(
|
||||
db
|
||||
.selectFrom("TournamentOrganization")
|
||||
.innerJoin(
|
||||
"UserSubmittedImage",
|
||||
"TournamentOrganization.avatarImgId",
|
||||
"UserSubmittedImage.id",
|
||||
)
|
||||
.select([
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganization.name",
|
||||
"TournamentOrganization.slug",
|
||||
"UserSubmittedImage.url as avatarUrl",
|
||||
])
|
||||
.whereRef("TournamentOrganization.id", "=", organizationId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function findById({
|
||||
id,
|
||||
includeMapPool = false,
|
||||
|
|
@ -80,7 +99,7 @@ export async function findById({
|
|||
)
|
||||
.innerJoin("User", "CalendarEvent.authorId", "User.id")
|
||||
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select([
|
||||
.select(({ ref }) => [
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.description",
|
||||
"CalendarEvent.discordInviteCode",
|
||||
|
|
@ -99,6 +118,9 @@ export async function findById({
|
|||
"User.discordId",
|
||||
"User.discordAvatar",
|
||||
hasBadge,
|
||||
tournamentOrganization(ref("CalendarEvent.organizationId")).as(
|
||||
"organization",
|
||||
),
|
||||
])
|
||||
.where("CalendarEvent.id", "=", id)
|
||||
.orderBy("CalendarEventDate.startTime", "asc")
|
||||
|
|
@ -147,7 +169,7 @@ export async function findAllBetweenTwoTimestamps({
|
|||
(join) =>
|
||||
join.onRef("CalendarEventRanks.id", "=", "CalendarEventDate.id"),
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
.select(({ eb, ref }) => [
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.discordUrl",
|
||||
"CalendarEvent.bracketUrl",
|
||||
|
|
@ -158,6 +180,9 @@ export async function findAllBetweenTwoTimestamps({
|
|||
"CalendarEventDate.startTime",
|
||||
"User.username",
|
||||
"CalendarEventRanks.nthAppearance",
|
||||
tournamentOrganization(ref("CalendarEvent.organizationId")).as(
|
||||
"organization",
|
||||
),
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select(["UserSubmittedImage.url"])
|
||||
|
|
@ -365,6 +390,7 @@ export async function findResultsByEventId(eventId: number) {
|
|||
"User.username",
|
||||
"User.discordId",
|
||||
"User.discordAvatar",
|
||||
"User.customUrl",
|
||||
])
|
||||
.whereRef(
|
||||
"CalendarEventResultPlayer.teamId",
|
||||
|
|
@ -411,6 +437,7 @@ type CreateArgs = Pick<
|
|||
| "description"
|
||||
| "discordInviteCode"
|
||||
| "bracketUrl"
|
||||
| "organizationId"
|
||||
> & {
|
||||
startTimes: Array<Tables["CalendarEventDate"]["startTime"]>;
|
||||
badges: Array<Tables["CalendarEventBadge"]["badgeId"]>;
|
||||
|
|
@ -520,6 +547,7 @@ export async function create(args: CreateArgs) {
|
|||
discordInviteCode: args.discordInviteCode,
|
||||
bracketUrl: args.bracketUrl,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
avatarMetadata: args.avatarMetadata
|
||||
? JSON.stringify(args.avatarMetadata)
|
||||
: null,
|
||||
|
|
@ -598,6 +626,7 @@ export async function update(args: UpdateArgs) {
|
|||
? JSON.stringify(args.avatarMetadata)
|
||||
: null,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
})
|
||||
.where("id", "=", args.eventId)
|
||||
.returning("tournamentId")
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
|
||||
const commonArgs = {
|
||||
authorId: user.id,
|
||||
organizationId: data.organizationId ?? null,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
rules: data.rules,
|
||||
|
|
@ -182,6 +183,7 @@ export const newCalendarEventActionSchema = z
|
|||
.object({
|
||||
eventToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
tournamentToCopyId: z.preprocess(actualNumber, id.nullish()),
|
||||
organizationId: z.preprocess(actualNumber, id.nullish()),
|
||||
name: z
|
||||
.string()
|
||||
.min(CALENDAR_EVENT.NAME_MIN_LENGTH)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { requireUser } from "~/features/auth/core/user.server";
|
|||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { canEditCalendarEvent } from "~/permissions";
|
||||
import { validate } from "~/utils/remix";
|
||||
|
|
@ -78,5 +79,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
: undefined,
|
||||
title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]),
|
||||
canCreateTournament: userCanCreateTournament,
|
||||
organizations: await TournamentOrganizationRepository.findByOrganizerUserId(
|
||||
user.id,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ function EventForm() {
|
|||
)}
|
||||
<NameInput />
|
||||
<DescriptionTextarea supportsMarkdown={isTournament} />
|
||||
<OrganizationSelect />
|
||||
{isTournament ? <RulesTextarea supportsMarkdown /> : null}
|
||||
<DatesInput allowMultiDate={!isTournament} />
|
||||
{!isTournament ? <BracketUrlInput /> : null}
|
||||
|
|
@ -276,6 +277,32 @@ function DescriptionTextarea({
|
|||
);
|
||||
}
|
||||
|
||||
function OrganizationSelect() {
|
||||
const id = React.useId();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const baseEvent = useBaseEvent();
|
||||
|
||||
if (data.organizations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>Organization</Label>
|
||||
<select
|
||||
id={id}
|
||||
name="organizationId"
|
||||
defaultValue={baseEvent?.organization?.id}
|
||||
>
|
||||
<option>Select an organization</option>
|
||||
{data.organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesTextarea({ supportsMarkdown }: { supportsMarkdown?: boolean }) {
|
||||
const baseEvent = useBaseEvent();
|
||||
const [value, setValue] = React.useState(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { Flipped, Flipper } from "react-flip-toolkit";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Label } from "~/components/Label";
|
||||
|
|
@ -41,6 +42,7 @@ import {
|
|||
calendarReportWinnersPage,
|
||||
navIconUrl,
|
||||
resolveBaseUrl,
|
||||
tournamentOrganizationPage,
|
||||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
|
|
@ -451,11 +453,32 @@ function EventsList({
|
|||
minute: "numeric",
|
||||
})}
|
||||
</time>
|
||||
<div className="calendar__event__author">
|
||||
{t("from", {
|
||||
author: calendarEvent.username,
|
||||
})}
|
||||
</div>
|
||||
{calendarEvent.organization ? (
|
||||
<Link
|
||||
to={tournamentOrganizationPage({
|
||||
organizationSlug: calendarEvent.organization.slug,
|
||||
})}
|
||||
className="stack horizontal sm items-center text-xs text-main-forced"
|
||||
>
|
||||
<Avatar
|
||||
url={
|
||||
calendarEvent.organization.avatarUrl
|
||||
? userSubmittedImage(
|
||||
calendarEvent.organization.avatarUrl,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
size="xxs"
|
||||
/>
|
||||
{calendarEvent.organization.name}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="calendar__event__author">
|
||||
{t("from", {
|
||||
author: calendarEvent.username,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{sectionWeekday !== eventWeekday ? (
|
||||
<div className="text-xxs font-bold text-theme-secondary ml-auto">
|
||||
{eventWeekday}
|
||||
|
|
|
|||
118
app/features/img-upload/actions/upload.server.ts
Normal file
118
app/features/img-upload/actions/upload.server.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import type { ActionFunctionArgs, UploadHandler } from "@remix-run/node";
|
||||
import {
|
||||
unstable_composeUploadHandlers as composeUploadHandlers,
|
||||
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
|
||||
unstable_parseMultipartFormData as parseMultipartFormData,
|
||||
redirect,
|
||||
} from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { findByIdentifier, isTeamOwner } from "~/features/team";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
import { canEditTournamentOrganization } from "~/features/tournament-organization/tournament-organization-utils";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
parseSearchParams,
|
||||
unauthorizedIfFalsy,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
|
||||
import { addNewImage } from "../queries/addNewImage";
|
||||
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
|
||||
import { s3UploadHandler } from "../s3.server";
|
||||
import { MAX_UNVALIDATED_IMG_COUNT } from "../upload-constants";
|
||||
import { requestToImgType } from "../upload-utils";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const validatedType = requestToImgType(request);
|
||||
validate(validatedType, "Invalid image type");
|
||||
|
||||
const team =
|
||||
validatedType === "team-pfp" || validatedType === "team-banner"
|
||||
? await validatedTeam(user)
|
||||
: undefined;
|
||||
const organization =
|
||||
validatedType === "org-pfp"
|
||||
? await validatedOrg({ user, request })
|
||||
: undefined;
|
||||
|
||||
// TODO: graceful error handling when uploading many images
|
||||
validate(
|
||||
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
|
||||
"Too many unvalidated images",
|
||||
);
|
||||
|
||||
const uploadHandler: UploadHandler = composeUploadHandlers(
|
||||
s3UploadHandler(),
|
||||
createMemoryUploadHandler(),
|
||||
);
|
||||
const formData = await parseMultipartFormData(request, uploadHandler);
|
||||
const imgSrc = formData.get("img") as string | null;
|
||||
invariant(imgSrc);
|
||||
|
||||
const urlParts = imgSrc.split("/");
|
||||
const fileName = urlParts[urlParts.length - 1];
|
||||
invariant(fileName);
|
||||
|
||||
const shouldAutoValidate =
|
||||
Boolean(user.patronTier) || validatedType === "org-pfp";
|
||||
|
||||
addNewImage({
|
||||
submitterUserId: user.id,
|
||||
teamId: team?.id,
|
||||
organizationId: organization?.id,
|
||||
type: validatedType,
|
||||
url: fileName,
|
||||
validatedAt: shouldAutoValidate
|
||||
? dateToDatabaseTimestamp(new Date())
|
||||
: null,
|
||||
});
|
||||
|
||||
if (shouldAutoValidate) {
|
||||
if (team) {
|
||||
throw redirect(teamPage(team?.customUrl));
|
||||
}
|
||||
if (organization) {
|
||||
throw redirect(
|
||||
tournamentOrganizationPage({ organizationSlug: organization.slug }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
async function validatedTeam(user: { id: number }) {
|
||||
const team = await TeamRepository.findByUserId(user.id);
|
||||
|
||||
validate(team, "You must be on a team to upload images");
|
||||
const detailed = findByIdentifier(team.customUrl);
|
||||
validate(
|
||||
detailed && isTeamOwner({ team: detailed.team, user }),
|
||||
"You must be the team owner to upload images",
|
||||
);
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
async function validatedOrg({
|
||||
user,
|
||||
request,
|
||||
}: { user: { id: number }; request: Request }) {
|
||||
const { slug } = parseSearchParams({
|
||||
request,
|
||||
schema: z.object({ slug: z.string() }),
|
||||
});
|
||||
const organization = badRequestIfFalsy(
|
||||
await TournamentOrganizationRepository.findBySlug(slug),
|
||||
);
|
||||
|
||||
unauthorizedIfFalsy(canEditTournamentOrganization({ user, organization }));
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
|
@ -22,18 +22,26 @@ const updateTeamBannerStm = sql.prepare(/* sql */ `
|
|||
where "id" = @teamId
|
||||
`);
|
||||
|
||||
const updateOrganizationAvatarStm = sql.prepare(/* sql */ `
|
||||
update "TournamentOrganization"
|
||||
set "avatarImgId" = @avatarImgId
|
||||
where "id" = @organizationId
|
||||
`);
|
||||
|
||||
export const addNewImage = sql.transaction(
|
||||
({
|
||||
submitterUserId,
|
||||
url,
|
||||
validatedAt,
|
||||
teamId,
|
||||
organizationId,
|
||||
type,
|
||||
}: {
|
||||
submitterUserId: number;
|
||||
url: string;
|
||||
validatedAt: number | null;
|
||||
teamId: number;
|
||||
teamId?: number;
|
||||
organizationId?: number;
|
||||
type: ImageUploadType;
|
||||
}) => {
|
||||
const img = addImgStm.get({
|
||||
|
|
@ -46,6 +54,8 @@ export const addNewImage = sql.transaction(
|
|||
updateTeamAvatarStm.run({ avatarImgId: img.id, teamId });
|
||||
} else if (type === "team-banner") {
|
||||
updateTeamBannerStm.run({ bannerImgId: img.id, teamId });
|
||||
} else if (type === "org-pfp") {
|
||||
updateOrganizationAvatarStm.run({ avatarImgId: img.id, organizationId });
|
||||
}
|
||||
|
||||
return img;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { isMod } from "~/permissions";
|
|||
import {
|
||||
badRequestIfFalsy,
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { userSubmittedImage } from "~/utils/urls";
|
||||
|
|
@ -20,7 +20,7 @@ import { validateImageSchema } from "../upload-schemas.server";
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
schema: validateImageSchema,
|
||||
request,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,103 +1,40 @@
|
|||
import type {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
UploadHandler,
|
||||
} from "@remix-run/node";
|
||||
import {
|
||||
unstable_composeUploadHandlers as composeUploadHandlers,
|
||||
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
|
||||
unstable_parseMultipartFormData as parseMultipartFormData,
|
||||
redirect,
|
||||
} from "@remix-run/node";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { Main } from "~/components/Main";
|
||||
|
||||
import Compressor from "compressorjs";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Main } from "~/components/Main";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { findByIdentifier, isTeamOwner } from "~/features/team";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { validate } from "~/utils/remix";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import { addNewImage } from "../queries/addNewImage";
|
||||
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
|
||||
import { s3UploadHandler } from "../s3.server";
|
||||
import {
|
||||
MAX_UNVALIDATED_IMG_COUNT,
|
||||
imgTypeToDimensions,
|
||||
imgTypeToStyle,
|
||||
} from "../upload-constants";
|
||||
import { imgTypeToDimensions, imgTypeToStyle } from "../upload-constants";
|
||||
import type { ImageUploadType } from "../upload-types";
|
||||
import { requestToImgType } from "../upload-utils";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const team = await TeamRepository.findByUserId(user.id);
|
||||
|
||||
const validatedType = requestToImgType(request);
|
||||
validate(validatedType, "Invalid image type");
|
||||
|
||||
validate(team, "You must be on a team to upload images");
|
||||
const detailed = findByIdentifier(team.customUrl);
|
||||
validate(
|
||||
detailed && isTeamOwner({ team: detailed.team, user }),
|
||||
"You must be the team owner to upload images",
|
||||
);
|
||||
|
||||
// TODO: graceful error handling when uploading many images
|
||||
validate(
|
||||
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
|
||||
"Too many unvalidated images",
|
||||
);
|
||||
|
||||
const uploadHandler: UploadHandler = composeUploadHandlers(
|
||||
s3UploadHandler(),
|
||||
createMemoryUploadHandler(),
|
||||
);
|
||||
const formData = await parseMultipartFormData(request, uploadHandler);
|
||||
const imgSrc = formData.get("img") as string | null;
|
||||
invariant(imgSrc);
|
||||
|
||||
const urlParts = imgSrc.split("/");
|
||||
const fileName = urlParts[urlParts.length - 1];
|
||||
invariant(fileName);
|
||||
|
||||
const shouldAutoValidate = Boolean(user.patronTier);
|
||||
|
||||
addNewImage({
|
||||
submitterUserId: user.id,
|
||||
teamId: team.id,
|
||||
type: validatedType,
|
||||
url: fileName,
|
||||
validatedAt: shouldAutoValidate
|
||||
? dateToDatabaseTimestamp(new Date())
|
||||
: null,
|
||||
});
|
||||
|
||||
if (shouldAutoValidate) {
|
||||
throw redirect(teamPage(team.customUrl));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
import { action } from "../actions/upload.server";
|
||||
export { action };
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const team = await TeamRepository.findByUserId(user.id);
|
||||
const validatedType = requestToImgType(request);
|
||||
|
||||
if (!validatedType || !team) {
|
||||
if (!validatedType) {
|
||||
throw redirect("/");
|
||||
}
|
||||
|
||||
const detailed = findByIdentifier(team.customUrl);
|
||||
if (validatedType === "team-pfp" || validatedType === "team-banner") {
|
||||
const team = await TeamRepository.findByUserId(user.id);
|
||||
if (!team) throw redirect("/");
|
||||
|
||||
if (!detailed || !isTeamOwner({ team: detailed.team, user })) {
|
||||
throw redirect("/");
|
||||
const detailed = findByIdentifier(team.customUrl);
|
||||
|
||||
if (!detailed || !isTeamOwner({ team: detailed.team, user })) {
|
||||
throw redirect("/");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -142,16 +79,18 @@ export default function FileUploadPage() {
|
|||
height,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-lighter">
|
||||
{t("common:upload.commonExplanation")}{" "}
|
||||
{data.unvalidatedImages ? (
|
||||
<span>
|
||||
{t("common:upload.afterExplanation", {
|
||||
count: data.unvalidatedImages,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{data.type === "team-banner" || data.type === "team-pfp" ? (
|
||||
<div className="text-sm text-lighter">
|
||||
{t("common:upload.commonExplanation")}{" "}
|
||||
{data.unvalidatedImages ? (
|
||||
<span>
|
||||
{t("common:upload.afterExplanation", {
|
||||
count: data.unvalidatedImages,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="img-field">{t("common:upload.imageToUpload")}</label>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,19 @@ import type { ImageUploadType } from "./upload-types";
|
|||
|
||||
export const MAX_UNVALIDATED_IMG_COUNT = 5;
|
||||
|
||||
export const IMAGE_TYPES = ["team-pfp", "team-banner"] as const;
|
||||
export const IMAGE_TYPES = ["team-pfp", "org-pfp", "team-banner"] as const;
|
||||
|
||||
export const imgTypeToDimensions: Record<
|
||||
ImageUploadType,
|
||||
{ width: number; height: number }
|
||||
> = {
|
||||
"team-pfp": { width: 400, height: 400 },
|
||||
"org-pfp": { width: 400, height: 400 },
|
||||
"team-banner": { width: 1000, height: 500 },
|
||||
};
|
||||
|
||||
export const imgTypeToStyle: Record<ImageUploadType, React.CSSProperties> = {
|
||||
"team-pfp": { borderRadius: "100%", width: "144px", height: "144px" },
|
||||
"org-pfp": { borderRadius: "100%", width: "144px", height: "144px" },
|
||||
"team-banner": { borderRadius: "var(--rounded)", width: "100%" },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node";
|
|||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { LFG_PAGE } from "~/utils/urls";
|
||||
import { falsyToNull, id } from "~/utils/zod";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
|
|
@ -11,7 +11,7 @@ import { LFG, TEAM_POST_TYPES, TIMEZONES } from "../lfg-constants";
|
|||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import type { ActionFunctionArgs } from "@remix-run/node";
|
|||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { isAdmin } from "~/permissions";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ function useSearchParamPersistedMapPool() {
|
|||
: {
|
||||
pool: newMapPool.serialized,
|
||||
},
|
||||
{ replace: true, state: { scroll: false } },
|
||||
{ replace: true, preventScrollReset: true },
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ function useSearchParamPersistedMapPool() {
|
|||
newSearchParams.delete("readonly");
|
||||
setSearchParams(newSearchParams, {
|
||||
replace: false,
|
||||
state: { scroll: false },
|
||||
preventScrollReset: true,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function useObjectDamage() {
|
|||
[DAMAGE_TYPE_SP_KEY]: newDamageType ?? "",
|
||||
[MULTI_SHOT_SP_KEY]: String(newIsMultiShot),
|
||||
},
|
||||
{ replace: true, state: { scroll: false } },
|
||||
{ replace: true, preventScrollReset: true },
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
canAddCommentToSuggestionFE,
|
||||
} from "~/permissions";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { actualNumber, trimmedString } from "~/utils/zod";
|
||||
import type { PlusSuggestionsLoaderData } from "./plus.suggestions";
|
||||
|
|
@ -40,7 +40,7 @@ const commentActionSchema = z.object({
|
|||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: commentActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
import { atOrError } from "~/utils/arrays";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
|
|
@ -55,7 +55,7 @@ const commentActionSchema = z.object({
|
|||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: commentActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
} from "~/permissions";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { userPage } from "~/utils/urls";
|
||||
|
|
@ -65,7 +65,7 @@ const suggestionActionSchema = z.union([
|
|||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: suggestionActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
import { isVotingActive } from "~/features/plus-voting/core/voting-time";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseRequestFormData } from "~/utils/remix";
|
||||
import { parseRequestPayload } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertType, assertUnreachable } from "~/utils/types";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
|
|
@ -47,7 +47,7 @@ const votingActionSchema = z.object({
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: votingActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { useIsMounted } from "~/hooks/useIsMounted";
|
|||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { type SendouRouteHandle, parseRequestFormData } from "~/utils/remix";
|
||||
import { type SendouRouteHandle, parseRequestPayload } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_PAGE,
|
||||
|
|
@ -70,7 +70,7 @@ export const handle: SendouRouteHandle = {
|
|||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: settingsActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import { useWindowSize } from "~/hooks/useWindowSize";
|
|||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
|
|
@ -104,7 +104,7 @@ export const meta: MetaFunction = () => {
|
|||
// and when we return null we just force a refresh
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import invariant from "~/utils/invariant";
|
|||
import { logger } from "~/utils/logger";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { inGameNameWithoutDiscriminator, makeTitle } from "~/utils/strings";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
|
@ -141,7 +141,7 @@ export const handle: SendouRouteHandle = {
|
|||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const matchId = matchIdFromParams(params);
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: matchSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import * as QRepository from "~/features/sendouq/QRepository.server";
|
|||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
|
|
@ -55,7 +55,7 @@ export type SendouQPreparingAction = typeof action;
|
|||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: preparingSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { joinListToNaturalString } from "~/utils/arrays";
|
|||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
|
|
@ -101,7 +101,7 @@ const validateCanJoinQ = async (user: { id: number; discordId: string }) => {
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: frontPageSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { mySlugify, teamPage } from "~/utils/urls";
|
||||
import { allTeams } from "../queries/allTeams.server";
|
||||
import { createNewTeam } from "../queries/createNewTeam.server";
|
||||
|
|
@ -9,7 +9,7 @@ import { createTeamSchema } from "../team-schemas.server";
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: createTeamSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import * as LFGRepository from "~/features/lfg/LFGRepository.server";
|
|||
import {
|
||||
type SendouRouteHandle,
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { makeTitle, pathnameFromPotentialURL } from "~/utils/strings";
|
||||
|
|
@ -79,7 +79,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
|
||||
validate(isTeamOwner({ team, user }), "You are not the team owner");
|
||||
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editTeamSchema,
|
||||
});
|
||||
|
|
@ -175,12 +175,12 @@ function ImageUploadLinks() {
|
|||
<Label>{t("team:forms.fields.uploadImages")}</Label>
|
||||
<ol className="team__image-links-list">
|
||||
<li>
|
||||
<Link to={uploadImagePage("team-pfp")}>
|
||||
<Link to={uploadImagePage({ type: "team-pfp" })}>
|
||||
{t("team:forms.fields.uploadImages.pfp")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to={uploadImagePage("team-banner")}>
|
||||
<Link to={uploadImagePage({ type: "team-banner" })}>
|
||||
{t("team:forms.fields.uploadImages.banner")}
|
||||
</Link>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { useBaseUrl } from "~/hooks/useBaseUrl";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
|
|
@ -54,7 +54,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
validate(isTeamOwner({ team, user }), "Only team owner can manage roster");
|
||||
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: manageRosterSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1136,12 +1136,37 @@ export class Tournament {
|
|||
});
|
||||
}
|
||||
|
||||
isAdmin(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
if (
|
||||
this.ctx.organization?.members.some(
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ctx.author.id === user.id;
|
||||
}
|
||||
|
||||
isOrganizer(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
if (this.ctx.author.id === user.id) return true;
|
||||
|
||||
if (
|
||||
this.ctx.organization?.members.some(
|
||||
(member) =>
|
||||
member.userId === user.id &&
|
||||
["ADMIN", "ORGANIZER"].includes(member.role),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ctx.staff.some(
|
||||
(staff) => staff.id === user.id && staff.role === "ORGANIZER",
|
||||
);
|
||||
|
|
@ -1153,16 +1178,19 @@ export class Tournament {
|
|||
|
||||
if (this.ctx.author.id === user.id) return true;
|
||||
|
||||
if (
|
||||
this.ctx.organization?.members.some(
|
||||
(member) =>
|
||||
member.userId === user.id &&
|
||||
["ADMIN", "ORGANIZER", "STREAMER"].includes(member.role),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ctx.staff.some(
|
||||
(staff) =>
|
||||
staff.id === user.id && ["ORGANIZER", "STREAMER"].includes(staff.role),
|
||||
);
|
||||
}
|
||||
|
||||
isAdmin(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
return this.ctx.author.id === user.id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1978,6 +1978,7 @@ export const PADDLING_POOL_257 = () =>
|
|||
},
|
||||
ctx: {
|
||||
id: 27,
|
||||
organization: null,
|
||||
eventId: 1352,
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
|
|
@ -7884,6 +7885,7 @@ export const PADDLING_POOL_255 = () =>
|
|||
},
|
||||
ctx: {
|
||||
id: 18,
|
||||
organization: null,
|
||||
eventId: 1286,
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
|
|
@ -14133,6 +14135,7 @@ export const IN_THE_ZONE_32 = () =>
|
|||
},
|
||||
ctx: {
|
||||
id: 11,
|
||||
organization: null,
|
||||
eventId: 1134,
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export const testTournament = (
|
|||
eventId: 1,
|
||||
id: 1,
|
||||
description: null,
|
||||
organization: null,
|
||||
rules: null,
|
||||
logoUrl: null,
|
||||
logoSrc: "/test.png",
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
|||
import { nullFilledArray, removeDuplicates } from "~/utils/arrays";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOU_INK_BASE_URL,
|
||||
|
|
@ -78,7 +78,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
const user = await requireUser(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const data = await parseRequestFormData({ request, schema: bracketSchema });
|
||||
const data = await parseRequestPayload({ request, schema: bracketSchema });
|
||||
const manager = getServerTournamentManager();
|
||||
|
||||
switch (data._action) {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
|||
import { canReportTournamentScore } from "~/permissions";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
tournamentBracketsPage,
|
||||
|
|
@ -63,7 +63,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
const user = await requireUser(request);
|
||||
const matchId = matchIdFromParams(params);
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: matchSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,403 @@
|
|||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import { mySlugify, userSubmittedImage } from "~/utils/urls";
|
||||
import { HACKY_resolvePicture } from "../tournament/tournament-utils";
|
||||
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "./tournament-organization-constants";
|
||||
|
||||
interface CreateArgs {
|
||||
ownerId: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function create(args: CreateArgs) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const org = await trx
|
||||
.insertInto("TournamentOrganization")
|
||||
.values({
|
||||
name: args.name,
|
||||
slug: mySlugify(args.name),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return trx
|
||||
.insertInto("TournamentOrganizationMember")
|
||||
.values({
|
||||
organizationId: org.id,
|
||||
userId: args.ownerId,
|
||||
role: "ADMIN",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
export function findBySlug(slug: string) {
|
||||
return db
|
||||
.selectFrom("TournamentOrganization")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"UserSubmittedImage.id",
|
||||
"TournamentOrganization.avatarImgId",
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganization.name",
|
||||
"TournamentOrganization.description",
|
||||
"TournamentOrganization.socials",
|
||||
"TournamentOrganization.slug",
|
||||
"UserSubmittedImage.url as avatarUrl",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganizationMember")
|
||||
.innerJoin("User", "User.id", "TournamentOrganizationMember.userId")
|
||||
.select([
|
||||
"TournamentOrganizationMember.role",
|
||||
"TournamentOrganizationMember.roleDisplayName",
|
||||
...COMMON_USER_FIELDS,
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
"=",
|
||||
"TournamentOrganization.id",
|
||||
),
|
||||
).as("members"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganizationSeries")
|
||||
.select([
|
||||
"TournamentOrganizationSeries.id",
|
||||
"TournamentOrganizationSeries.name",
|
||||
"TournamentOrganizationSeries.substringMatches",
|
||||
"TournamentOrganizationSeries.showLeaderboard",
|
||||
"TournamentOrganizationSeries.description",
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentOrganizationSeries.organizationId",
|
||||
"=",
|
||||
"TournamentOrganization.id",
|
||||
),
|
||||
).as("series"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganizationBadge")
|
||||
.innerJoin("Badge", "Badge.id", "TournamentOrganizationBadge.badgeId")
|
||||
.select(["Badge.id", "Badge.displayName", "Badge.code", "Badge.hue"])
|
||||
.whereRef(
|
||||
"TournamentOrganizationBadge.organizationId",
|
||||
"=",
|
||||
"TournamentOrganization.id",
|
||||
),
|
||||
).as("badges"),
|
||||
])
|
||||
.where("TournamentOrganization.slug", "=", slug)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export function findByOrganizerUserId(userId: number) {
|
||||
return db
|
||||
.selectFrom("TournamentOrganizationMember")
|
||||
.innerJoin(
|
||||
"TournamentOrganization",
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
)
|
||||
.select(["TournamentOrganization.id", "TournamentOrganization.name"])
|
||||
.where("TournamentOrganizationMember.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb("TournamentOrganizationMember.role", "=", "ADMIN").or(
|
||||
"TournamentOrganizationMember.role",
|
||||
"=",
|
||||
"ORGANIZER",
|
||||
),
|
||||
)
|
||||
.orderBy("TournamentOrganization.id asc")
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface FindEventsByMonthArgs {
|
||||
month: number;
|
||||
year: number;
|
||||
organizationId: number;
|
||||
}
|
||||
|
||||
const findEventsBaseQuery = (organizationId: number) =>
|
||||
db
|
||||
.selectFrom("CalendarEvent")
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEventDate.eventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"CalendarEvent.id as eventId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.tournamentId",
|
||||
"CalendarEventDate.startTime",
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select(["UserSubmittedImage.url"])
|
||||
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
|
||||
.as("logoUrl"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.leftJoin("AllTeam", "TournamentTeam.teamId", "AllTeam.id")
|
||||
.leftJoin("UserSubmittedImage as u1", "AllTeam.avatarImgId", "u1.id")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage as u2",
|
||||
"TournamentTeam.avatarImgId",
|
||||
"u2.id",
|
||||
)
|
||||
.select(({ eb: innerEb }) => [
|
||||
"TournamentTeam.name",
|
||||
innerEb.fn.coalesce("u1.url", "u2.url").as("avatarUrl"),
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
|
||||
.select(["User.discordAvatar", "User.discordId"])
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.orderBy("User.id asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentResult.tournamentId",
|
||||
"=",
|
||||
"CalendarEvent.tournamentId",
|
||||
)
|
||||
.where("TournamentResult.placement", "=", 1),
|
||||
).as("tournamentWinners"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("CalendarEventResultTeam")
|
||||
.select(({ eb: innerEb }) => [
|
||||
"CalendarEventResultTeam.name",
|
||||
sql<null>`null`.as("avatarUrl"),
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("CalendarEventResultPlayer")
|
||||
.innerJoin(
|
||||
"User",
|
||||
"User.id",
|
||||
"CalendarEventResultPlayer.userId",
|
||||
)
|
||||
.select(["User.discordAvatar", "User.discordId"])
|
||||
.whereRef(
|
||||
"CalendarEventResultPlayer.teamId",
|
||||
"=",
|
||||
"CalendarEventResultTeam.id",
|
||||
)
|
||||
.orderBy("User.id asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.whereRef("CalendarEventResultTeam.eventId", "=", "CalendarEvent.id")
|
||||
.where("CalendarEventResultTeam.placement", "=", 1),
|
||||
).as("eventWinners"),
|
||||
])
|
||||
.where("CalendarEvent.organizationId", "=", organizationId);
|
||||
|
||||
const mapEvent = <
|
||||
T extends {
|
||||
tournamentId: number | null;
|
||||
logoUrl: string | null;
|
||||
name: string;
|
||||
},
|
||||
>(
|
||||
event: T,
|
||||
) => {
|
||||
return {
|
||||
...event,
|
||||
logoUrl: !event.tournamentId
|
||||
? null
|
||||
: event.logoUrl
|
||||
? userSubmittedImage(event.logoUrl)
|
||||
: HACKY_resolvePicture(event),
|
||||
};
|
||||
};
|
||||
|
||||
export async function findEventsByMonth({
|
||||
month,
|
||||
year,
|
||||
organizationId,
|
||||
}: FindEventsByMonthArgs) {
|
||||
const firstDayOfTheMonth = new Date(year, month, 1);
|
||||
const lastDayOfTheMonth = new Date(year, month + 1, 0);
|
||||
|
||||
const events = await findEventsBaseQuery(organizationId)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
">=",
|
||||
dateToDatabaseTimestamp(firstDayOfTheMonth),
|
||||
)
|
||||
.where(
|
||||
"CalendarEventDate.startTime",
|
||||
"<=",
|
||||
dateToDatabaseTimestamp(lastDayOfTheMonth),
|
||||
)
|
||||
.orderBy("CalendarEventDate.startTime asc")
|
||||
.execute();
|
||||
|
||||
return events.map(mapEvent);
|
||||
}
|
||||
|
||||
const findSeriesEventsBaseQuery = ({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
}: {
|
||||
organizationId: number;
|
||||
substringMatches: string[];
|
||||
}) =>
|
||||
findEventsBaseQuery(organizationId)
|
||||
.where((eb) =>
|
||||
eb.or(
|
||||
substringMatches.map((match) =>
|
||||
eb("CalendarEvent.name", "like", `%${match}%`),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy("CalendarEventDate.startTime desc");
|
||||
|
||||
export async function findPaginatedEventsBySeries({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
page,
|
||||
}: {
|
||||
organizationId: number;
|
||||
substringMatches: string[];
|
||||
page: number;
|
||||
}) {
|
||||
const events = await findSeriesEventsBaseQuery({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
})
|
||||
.limit(TOURNAMENT_SERIES_EVENTS_PER_PAGE)
|
||||
.offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE)
|
||||
.execute();
|
||||
|
||||
return events.map(mapEvent);
|
||||
}
|
||||
|
||||
export async function findAllEventsBySeries({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
}: {
|
||||
organizationId: number;
|
||||
substringMatches: string[];
|
||||
}) {
|
||||
const events = await findSeriesEventsBaseQuery({
|
||||
organizationId,
|
||||
substringMatches,
|
||||
}).execute();
|
||||
|
||||
return events.map(mapEvent);
|
||||
}
|
||||
|
||||
interface UpdateArgs
|
||||
extends Pick<
|
||||
Tables["TournamentOrganization"],
|
||||
"id" | "name" | "description" | "socials"
|
||||
> {
|
||||
members: Array<
|
||||
Pick<
|
||||
Tables["TournamentOrganizationMember"],
|
||||
"role" | "roleDisplayName" | "userId"
|
||||
>
|
||||
>;
|
||||
series: Array<
|
||||
Pick<Tables["TournamentOrganizationSeries"], "description" | "name"> & {
|
||||
showLeaderboard: boolean;
|
||||
}
|
||||
>;
|
||||
badges: number[];
|
||||
}
|
||||
|
||||
export function update({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
socials,
|
||||
members,
|
||||
series,
|
||||
badges,
|
||||
}: UpdateArgs) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const updatedOrg = await trx
|
||||
.updateTable("TournamentOrganization")
|
||||
.set({
|
||||
name,
|
||||
description,
|
||||
slug: mySlugify(name),
|
||||
socials: socials ? JSON.stringify(socials) : null,
|
||||
})
|
||||
.where("id", "=", id)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.deleteFrom("TournamentOrganizationMember")
|
||||
.where("organizationId", "=", id)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.insertInto("TournamentOrganizationMember")
|
||||
.values(
|
||||
members.map((member) => ({
|
||||
organizationId: id,
|
||||
...member,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await trx
|
||||
.deleteFrom("TournamentOrganizationSeries")
|
||||
.where("organizationId", "=", id)
|
||||
.execute();
|
||||
|
||||
if (series.length > 0) {
|
||||
await trx
|
||||
.insertInto("TournamentOrganizationSeries")
|
||||
.values(
|
||||
series.map((s) => ({
|
||||
organizationId: id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
substringMatches: JSON.stringify([s.name.toLowerCase()]),
|
||||
showLeaderboard: Number(s.showLeaderboard),
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.deleteFrom("TournamentOrganizationBadge")
|
||||
.where("TournamentOrganizationBadge.organizationId", "=", id)
|
||||
.execute();
|
||||
|
||||
if (badges.length > 0) {
|
||||
await trx
|
||||
.insertInto("TournamentOrganizationBadge")
|
||||
.values(
|
||||
badges.map((badgeId) => ({
|
||||
organizationId: id,
|
||||
badgeId,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
return updatedOrg;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import i18next from "~/modules/i18n/i18next.server";
|
||||
import { valueArrayToDBFormat } from "~/utils/form";
|
||||
import {
|
||||
actionError,
|
||||
parseRequestPayload,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix";
|
||||
import { tournamentOrganizationPage } from "~/utils/urls";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
import { organizationEditSchema } from "../routes/org.$slug.edit";
|
||||
import { canEditTournamentOrganization } from "../tournament-organization-utils";
|
||||
import { organizationFromParams } from "../tournament-organization-utils.server";
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: organizationEditSchema,
|
||||
});
|
||||
const t = await i18next.getFixedT(request, ["org"]);
|
||||
|
||||
const organization = await organizationFromParams(params);
|
||||
|
||||
unauthorizedIfFalsy(canEditTournamentOrganization({ organization, user }));
|
||||
|
||||
if (
|
||||
!data.members.some(
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
)
|
||||
) {
|
||||
return actionError<typeof organizationEditSchema>({
|
||||
msg: t("org:edit.form.errors.noUnadmin"),
|
||||
field: "members.root",
|
||||
});
|
||||
}
|
||||
|
||||
const newOrganization = await TournamentOrganizationRepository.update({
|
||||
id: organization.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
socials: valueArrayToDBFormat(data.socials),
|
||||
members: data.members,
|
||||
series: data.series,
|
||||
badges: data.badges,
|
||||
});
|
||||
|
||||
return redirect(
|
||||
tournamentOrganizationPage({ organizationSlug: newOrganization.slug }),
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import clsx from "clsx";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import { databaseTimestampToDate, nullPaddedDatesOfMonth } from "~/utils/dates";
|
||||
import type { loader } from "../loaders/org.$slug.server";
|
||||
|
||||
interface EventCalendarProps {
|
||||
month: number;
|
||||
year: number;
|
||||
events: SerializeFrom<typeof loader>["events"];
|
||||
fallbackLogoUrl: string;
|
||||
}
|
||||
|
||||
// TODO: i18n
|
||||
const DAY_HEADERS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
export function EventCalendar({
|
||||
month,
|
||||
year,
|
||||
events,
|
||||
fallbackLogoUrl,
|
||||
}: EventCalendarProps) {
|
||||
const dates = nullPaddedDatesOfMonth({ month, year });
|
||||
|
||||
return (
|
||||
<div className="org__calendar__container">
|
||||
<MonthSelector month={month} year={year} />
|
||||
<div className="org__calendar">
|
||||
{DAY_HEADERS.map((day) => (
|
||||
<div key={day} className="org__calendar__day-header">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{dates.map((date, i) => {
|
||||
const daysEvents = events.filter(
|
||||
(event) =>
|
||||
databaseTimestampToDate(event.startTime).getDate() ===
|
||||
date?.getDate(),
|
||||
);
|
||||
|
||||
return (
|
||||
<EventCalendarCell
|
||||
key={i}
|
||||
date={date}
|
||||
events={daysEvents}
|
||||
fallbackLogoUrl={fallbackLogoUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventCalendarCell({
|
||||
date,
|
||||
events,
|
||||
fallbackLogoUrl,
|
||||
}: {
|
||||
date: Date | null;
|
||||
events: SerializeFrom<typeof loader>["events"];
|
||||
fallbackLogoUrl: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx("org__calendar__day", {
|
||||
org__calendar__day__previous: !date,
|
||||
org__calendar__day__today:
|
||||
date?.getDate() === new Date().getDate() &&
|
||||
date?.getMonth() === new Date().getMonth() &&
|
||||
date?.getFullYear() === new Date().getFullYear(),
|
||||
})}
|
||||
>
|
||||
<div className="org__calendar__day__date">{date?.getDate()}</div>
|
||||
{events.length === 1 ? (
|
||||
<img
|
||||
className="org__calendar__day__logo"
|
||||
src={events[0].logoUrl ?? fallbackLogoUrl}
|
||||
width={32}
|
||||
height={32}
|
||||
alt={events[0].name}
|
||||
/>
|
||||
) : null}
|
||||
{events.length > 1 ? (
|
||||
<div className="org__calendar__day__many-events">{events.length}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const monthYearSearchParams = ({ month, year }: MonthYear) =>
|
||||
new URLSearchParams([
|
||||
["month", String(month)],
|
||||
["year", String(year)],
|
||||
]).toString();
|
||||
function MonthSelector({ month, year }: { month: number; year: number }) {
|
||||
const date = new Date(Date.UTC(year, month, 1));
|
||||
|
||||
return (
|
||||
<div className="org__calendar__month-selector">
|
||||
<LinkButton
|
||||
variant="minimal"
|
||||
aria-label="Previous month"
|
||||
to={`?${monthYearSearchParams(
|
||||
month === 0
|
||||
? { month: 11, year: year - 1 }
|
||||
: {
|
||||
month: date.getMonth() - 1,
|
||||
year: date.getFullYear(),
|
||||
},
|
||||
)}`}
|
||||
>
|
||||
{"<"}
|
||||
</LinkButton>
|
||||
<div>
|
||||
{date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</div>
|
||||
<LinkButton
|
||||
variant="minimal"
|
||||
aria-label="Following month"
|
||||
to={`?${monthYearSearchParams(
|
||||
month === 11
|
||||
? { month: 0, year: year + 1 }
|
||||
: {
|
||||
month: date.getMonth() + 1,
|
||||
year: date.getFullYear(),
|
||||
},
|
||||
)}`}
|
||||
>
|
||||
{">"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import clsx from "clsx";
|
||||
import { LinkIcon } from "~/components/icons/Link";
|
||||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
import { TwitterIcon } from "~/components/icons/Twitter";
|
||||
import { YouTubeIcon } from "~/components/icons/YouTube";
|
||||
|
||||
export function SocialLinksList({ links }: { links: string[] }) {
|
||||
return (
|
||||
<div className="stack sm text-sm">
|
||||
{links.map((url, i) => {
|
||||
return <SocialLink key={i} url={url} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialLink({ url }: { url: string }) {
|
||||
const type = urlToLinkType(url);
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noreferrer" className="org__social-link">
|
||||
<div
|
||||
className={clsx("org__social-link__icon-container", {
|
||||
youtube: type === "youtube",
|
||||
twitter: type === "twitter",
|
||||
twitch: type === "twitch",
|
||||
})}
|
||||
>
|
||||
<SocialLinkIcon url={url} />
|
||||
</div>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialLinkIcon({ url }: { url: string }) {
|
||||
const type = urlToLinkType(url);
|
||||
|
||||
if (type === "twitter") {
|
||||
return <TwitterIcon />;
|
||||
}
|
||||
|
||||
if (type === "twitch") {
|
||||
return <TwitchIcon />;
|
||||
}
|
||||
|
||||
if (type === "youtube") {
|
||||
return <YouTubeIcon />;
|
||||
}
|
||||
|
||||
return <LinkIcon />;
|
||||
}
|
||||
|
||||
const urlToLinkType = (url: string) => {
|
||||
if (url.includes("twitter.com") || url.includes("x.com")) {
|
||||
return "twitter";
|
||||
}
|
||||
|
||||
if (url.includes("twitch.tv")) {
|
||||
return "twitch";
|
||||
}
|
||||
|
||||
if (url.includes("youtube.com")) {
|
||||
return "youtube";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
162
app/features/tournament-organization/core/leaderboards.server.ts
Normal file
162
app/features/tournament-organization/core/leaderboards.server.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import type * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
|
||||
const THIRD_PLACE_POINTS = 1;
|
||||
const SECOND_PLACE_POINTS = THIRD_PLACE_POINTS * 2;
|
||||
const FIRST_PLACE_POINTS = SECOND_PLACE_POINTS * 2;
|
||||
|
||||
type EventLeaderboardEvent = Unpacked<
|
||||
Awaited<
|
||||
ReturnType<typeof TournamentOrganizationRepository.findAllEventsBySeries>
|
||||
>
|
||||
>;
|
||||
|
||||
interface LeaderboardInfo {
|
||||
user: CommonUser;
|
||||
points: number;
|
||||
placements: {
|
||||
first: number;
|
||||
second: number;
|
||||
third: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function eventLeaderboards(
|
||||
events: Awaited<
|
||||
ReturnType<typeof TournamentOrganizationRepository.findAllEventsBySeries>
|
||||
>,
|
||||
) {
|
||||
const points = new Map<number, LeaderboardInfo>();
|
||||
|
||||
for (const event of events) {
|
||||
const eventsPoints = event.tournamentId
|
||||
? await tournamentPoints(event as typeof event & { tournamentId: number })
|
||||
: await calendarEventPoints(event);
|
||||
|
||||
mergeMaps(points, eventsPoints);
|
||||
}
|
||||
|
||||
return Array.from(points.values())
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.map((info) => ({ ...info, points: info.points.toFixed(2) }));
|
||||
}
|
||||
|
||||
async function tournamentPoints(
|
||||
event: EventLeaderboardEvent & { tournamentId: number },
|
||||
): Promise<Map<number, LeaderboardInfo>> {
|
||||
const results = await TournamentRepository.topThreeResultsByTournamentId(
|
||||
event.tournamentId,
|
||||
);
|
||||
|
||||
const leaderboardInfo = new Map<number, LeaderboardInfo>();
|
||||
|
||||
if (results.length === 0) return leaderboardInfo;
|
||||
|
||||
for (const result of results) {
|
||||
const teamSize = results.filter(
|
||||
(result2) => result.tournamentTeamId === result2.tournamentTeamId,
|
||||
).length;
|
||||
|
||||
leaderboardInfo.set(result.user.id, {
|
||||
user: result.user,
|
||||
points: pointsAdjustedToTeamSize({
|
||||
basePoints:
|
||||
result.placement === 1
|
||||
? FIRST_PLACE_POINTS
|
||||
: result.placement === 2
|
||||
? SECOND_PLACE_POINTS
|
||||
: THIRD_PLACE_POINTS,
|
||||
teamSize: teamSize,
|
||||
}),
|
||||
placements: {
|
||||
first: result.placement === 1 ? 1 : 0,
|
||||
second: result.placement === 2 ? 1 : 0,
|
||||
third: result.placement === 3 ? 1 : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return leaderboardInfo;
|
||||
}
|
||||
|
||||
async function calendarEventPoints(
|
||||
event: EventLeaderboardEvent,
|
||||
): Promise<Map<number, LeaderboardInfo>> {
|
||||
const results = await CalendarRepository.findResultsByEventId(event.eventId);
|
||||
|
||||
const leaderboardInfo = new Map<number, LeaderboardInfo>();
|
||||
|
||||
if (results.length === 0) return leaderboardInfo;
|
||||
|
||||
for (const placement of [1, 2, 3]) {
|
||||
const placementResults = results.filter(
|
||||
(result) => result.placement === placement,
|
||||
);
|
||||
|
||||
for (const team of placementResults) {
|
||||
const teamSize = team.players.filter((player) => player.id).length;
|
||||
|
||||
for (const player of team.players) {
|
||||
// not a connected user, reported as simple text
|
||||
if (!player.id) continue;
|
||||
|
||||
leaderboardInfo.set(player.id, {
|
||||
user: {
|
||||
customUrl: player.customUrl,
|
||||
discordAvatar: player.discordAvatar,
|
||||
discordId: player.discordId!,
|
||||
id: player.id,
|
||||
username: player.username!,
|
||||
},
|
||||
points: pointsAdjustedToTeamSize({
|
||||
basePoints:
|
||||
placement === 1
|
||||
? FIRST_PLACE_POINTS
|
||||
: placement === 2
|
||||
? SECOND_PLACE_POINTS
|
||||
: THIRD_PLACE_POINTS,
|
||||
teamSize: teamSize,
|
||||
}),
|
||||
placements: {
|
||||
first: placement === 1 ? 1 : 0,
|
||||
second: placement === 2 ? 1 : 0,
|
||||
third: placement === 3 ? 1 : 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return leaderboardInfo;
|
||||
}
|
||||
|
||||
function pointsAdjustedToTeamSize({
|
||||
basePoints,
|
||||
teamSize,
|
||||
}: { basePoints: number; teamSize: number }) {
|
||||
if (teamSize <= 4) return basePoints;
|
||||
|
||||
return (basePoints * 4) / teamSize;
|
||||
}
|
||||
|
||||
function mergeMaps(
|
||||
accMap: Map<number, LeaderboardInfo>,
|
||||
newMap: Map<number, LeaderboardInfo>,
|
||||
) {
|
||||
for (const [userId, info] of newMap) {
|
||||
const accPoints = accMap.get(userId);
|
||||
|
||||
if (!accPoints) {
|
||||
accMap.set(userId, info);
|
||||
continue;
|
||||
}
|
||||
|
||||
accPoints.points += info.points;
|
||||
accPoints.placements.first += info.placements.first;
|
||||
accPoints.placements.second += info.placements.second;
|
||||
accPoints.placements.third += info.placements.third;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
|
||||
import { unauthorizedIfFalsy } from "~/utils/remix";
|
||||
import { canEditTournamentOrganization } from "../tournament-organization-utils";
|
||||
import { organizationFromParams } from "../tournament-organization-utils.server";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
const organization = await organizationFromParams(params);
|
||||
|
||||
unauthorizedIfFalsy(canEditTournamentOrganization({ organization, user }));
|
||||
|
||||
return {
|
||||
organization,
|
||||
badgeOptions: await BadgeRepository.findByManagersList(
|
||||
organization.members.map((member) => member.id),
|
||||
),
|
||||
};
|
||||
}
|
||||
129
app/features/tournament-organization/loaders/org.$slug.server.ts
Normal file
129
app/features/tournament-organization/loaders/org.$slug.server.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { parseSafeSearchParams } from "~/utils/remix";
|
||||
import { id } from "~/utils/zod";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
import { eventLeaderboards } from "../core/leaderboards.server";
|
||||
import { TOURNAMENT_SERIES_LEADERBOARD_SIZE } from "../tournament-organization-constants";
|
||||
import { organizationFromParams } from "../tournament-organization-utils.server";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const user = await getUser(request);
|
||||
const {
|
||||
month = new Date().getMonth(),
|
||||
year = new Date().getFullYear(),
|
||||
page = 1,
|
||||
series: _seriesId,
|
||||
source,
|
||||
} = parseSafeSearchParams({
|
||||
request,
|
||||
schema: searchParamsSchema,
|
||||
}).data ?? {};
|
||||
|
||||
const organization = await organizationFromParams(params);
|
||||
|
||||
const seriesId =
|
||||
_seriesId ??
|
||||
organization.series.find((s) =>
|
||||
s.substringMatches.some((match) => source?.toLowerCase().includes(match)),
|
||||
)?.id;
|
||||
|
||||
const seriesInfo = async () => {
|
||||
const series = seriesId
|
||||
? organization.series.find((s) => s.id === seriesId)
|
||||
: null;
|
||||
|
||||
if (!series) return null;
|
||||
|
||||
const { leaderboard, ...rest } =
|
||||
(await seriesStuff({
|
||||
organizationId: organization.id,
|
||||
series,
|
||||
userId: user?.id,
|
||||
})) ?? {};
|
||||
|
||||
return {
|
||||
id: series.id,
|
||||
name: series.name,
|
||||
description: series.description,
|
||||
page,
|
||||
leaderboard: series.showLeaderboard ? leaderboard : null,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
const series = seriesId
|
||||
? organization.series.find((s) => s.id === seriesId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
organization,
|
||||
events: series
|
||||
? await TournamentOrganizationRepository.findPaginatedEventsBySeries({
|
||||
organizationId: organization.id,
|
||||
substringMatches: series.substringMatches,
|
||||
page,
|
||||
})
|
||||
: await TournamentOrganizationRepository.findEventsByMonth({
|
||||
month,
|
||||
year,
|
||||
organizationId: organization.id,
|
||||
}),
|
||||
series: await seriesInfo(),
|
||||
month,
|
||||
year,
|
||||
};
|
||||
}
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
month: z.coerce.number().int().min(0).max(11).optional(),
|
||||
year: z.coerce.number().int().min(2020).max(2100).optional(),
|
||||
series: id.optional(),
|
||||
page: z.coerce.number().int().min(1).max(100).optional(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
async function seriesStuff({
|
||||
organizationId,
|
||||
series,
|
||||
userId,
|
||||
}: {
|
||||
organizationId: number;
|
||||
series: NonNullable<
|
||||
Awaited<ReturnType<typeof TournamentOrganizationRepository.findBySlug>>
|
||||
>["series"][number];
|
||||
userId?: number;
|
||||
}) {
|
||||
const events = await TournamentOrganizationRepository.findAllEventsBySeries({
|
||||
organizationId,
|
||||
substringMatches: series.substringMatches,
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const fullLeaderboard = await eventLeaderboards(events);
|
||||
const leaderboard = fullLeaderboard.slice(
|
||||
0,
|
||||
TOURNAMENT_SERIES_LEADERBOARD_SIZE,
|
||||
);
|
||||
|
||||
const ownEntryIdx =
|
||||
userId && !leaderboard.some((entry) => entry.user.id === userId)
|
||||
? fullLeaderboard.findIndex((entry) => entry.user.id === userId)
|
||||
: -1;
|
||||
|
||||
return {
|
||||
leaderboard,
|
||||
ownEntry:
|
||||
ownEntryIdx !== -1
|
||||
? {
|
||||
entry: fullLeaderboard[ownEntryIdx],
|
||||
placement: ownEntryIdx + 1,
|
||||
}
|
||||
: null,
|
||||
eventsCount: events.length,
|
||||
logoUrl: events[0].logoUrl,
|
||||
established: events.at(-1)!.startTime,
|
||||
};
|
||||
}
|
||||
370
app/features/tournament-organization/routes/org.$slug.edit.tsx
Normal file
370
app/features/tournament-organization/routes/org.$slug.edit.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { AddFieldButton } from "~/components/form/AddFieldButton";
|
||||
import { FormFieldset } from "~/components/form/FormFieldset";
|
||||
import { MyForm } from "~/components/form/MyForm";
|
||||
import { SelectFormField } from "~/components/form/SelectFormField";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { TextArrayFormField } from "~/components/form/TextArrayFormField";
|
||||
import { TextFormField } from "~/components/form/TextFormField";
|
||||
import { ToggleFormField } from "~/components/form/ToggleFormField";
|
||||
import { UserSearchFormField } from "~/components/form/UserSearchFormField";
|
||||
import { TOURNAMENT_ORGANIZATION_ROLES } from "~/db/tables";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { wrapToValueStringArrayWithDefault } from "~/utils/form";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { mySlugify, uploadImagePage } from "~/utils/urls";
|
||||
import { falsyToNull, id } from "~/utils/zod";
|
||||
|
||||
import { action } from "../actions/org.$slug.edit.server";
|
||||
import { loader } from "../loaders/org.$slug.edit.server";
|
||||
import { handle, meta } from "../routes/org.$slug";
|
||||
export { loader, action, handle, meta };
|
||||
|
||||
const DESCRIPTION_MAX_LENGTH = 1_000;
|
||||
export const organizationEditSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(32)
|
||||
.refine((val) => mySlugify(val).length >= 2, {
|
||||
message: "Not enough non-special characters",
|
||||
}),
|
||||
description: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
),
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.number().int().positive(),
|
||||
role: z.enum(TOURNAMENT_ORGANIZATION_ROLES),
|
||||
roleDisplayName: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().trim().max(32).nullable(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.max(12)
|
||||
.refine(
|
||||
(arr) =>
|
||||
arr.map((x) => x.userId).length ===
|
||||
new Set(arr.map((x) => x.userId)).size,
|
||||
{
|
||||
message: "Same member listed twice",
|
||||
},
|
||||
),
|
||||
socials: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().trim().url().max(100).optional().or(z.literal("")),
|
||||
}),
|
||||
)
|
||||
.max(10)
|
||||
.refine(
|
||||
(arr) =>
|
||||
arr.map((x) => x.value).length ===
|
||||
new Set(arr.map((x) => x.value)).size,
|
||||
{
|
||||
message: "Duplicate social links",
|
||||
},
|
||||
),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(32),
|
||||
description: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().trim().max(DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
),
|
||||
showLeaderboard: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.max(10)
|
||||
.refine(
|
||||
(arr) =>
|
||||
arr.map((x) => x.name).length === new Set(arr.map((x) => x.name)).size,
|
||||
{
|
||||
message: "Duplicate series",
|
||||
},
|
||||
),
|
||||
badges: z.array(id).max(50),
|
||||
});
|
||||
|
||||
type FormFields = z.infer<typeof organizationEditSchema> & {
|
||||
members: Array<
|
||||
Omit<
|
||||
Unpacked<z.infer<typeof organizationEditSchema>["members"]>,
|
||||
"userId"
|
||||
> & {
|
||||
userId: number | null;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
export default function TournamentOrganizationEditPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["org", "common"]);
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<MyForm
|
||||
title={t("org:edit.form.title")}
|
||||
schema={organizationEditSchema}
|
||||
defaultValues={{
|
||||
name: data.organization.name,
|
||||
description: data.organization.description,
|
||||
socials: wrapToValueStringArrayWithDefault(data.organization.socials),
|
||||
members: data.organization.members.map((member) => ({
|
||||
userId: member.id,
|
||||
role: member.role,
|
||||
roleDisplayName: member.roleDisplayName,
|
||||
})),
|
||||
series: data.organization.series.map((series) => ({
|
||||
name: series.name,
|
||||
description: series.description,
|
||||
showLeaderboard: Boolean(series.showLeaderboard),
|
||||
})),
|
||||
badges: data.organization.badges.map((badge) => badge.id),
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to={uploadImagePage({
|
||||
type: "org-pfp",
|
||||
slug: data.organization.slug,
|
||||
})}
|
||||
className="text-sm font-bold"
|
||||
>
|
||||
{t("org:edit.form.uploadLogo")}
|
||||
</Link>
|
||||
|
||||
<TextFormField<FormFields> label={t("common:forms.name")} name="name" />
|
||||
|
||||
<TextAreaFormField<typeof organizationEditSchema>
|
||||
label={t("common:forms.description")}
|
||||
name="description"
|
||||
maxLength={DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<MembersFormField />
|
||||
|
||||
<TextArrayFormField<typeof organizationEditSchema>
|
||||
label={t("org:edit.form.socialLinks.title")}
|
||||
name="socials"
|
||||
defaultFieldValue=""
|
||||
/>
|
||||
|
||||
<SeriesFormField />
|
||||
|
||||
<BadgesFormField />
|
||||
</MyForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersFormField() {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext<FormFields>();
|
||||
const { fields, append, remove } = useFieldArray<FormFields>({
|
||||
name: "members",
|
||||
});
|
||||
const { t } = useTranslation(["org"]);
|
||||
|
||||
const rootError = errors.members?.root;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{t("org:edit.form.members.title")}</Label>
|
||||
<div className="stack md">
|
||||
{fields.map((field, i) => {
|
||||
return <MemberFieldset key={field.id} idx={i} remove={remove} />;
|
||||
})}
|
||||
<AddFieldButton
|
||||
onClick={() => {
|
||||
append({ role: "MEMBER", roleDisplayName: null, userId: null });
|
||||
}}
|
||||
/>
|
||||
{rootError && (
|
||||
<FormMessage type="error">{rootError.message as string}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberFieldset({
|
||||
idx,
|
||||
remove,
|
||||
}: { idx: number; remove: (idx: number) => void }) {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const { clearErrors } = useFormContext<FormFields>();
|
||||
|
||||
return (
|
||||
<FormFieldset
|
||||
title={`#${idx + 1}`}
|
||||
onRemove={() => {
|
||||
remove(idx);
|
||||
clearErrors("members");
|
||||
}}
|
||||
>
|
||||
<UserSearchFormField<FormFields>
|
||||
label={t("org:edit.form.members.user.title")}
|
||||
name={`members.${idx}.userId` as const}
|
||||
/>
|
||||
|
||||
<SelectFormField
|
||||
label={t("org:edit.form.members.role.title")}
|
||||
name={`members.${idx}.role` as const}
|
||||
values={TOURNAMENT_ORGANIZATION_ROLES.map((role) => ({
|
||||
value: role,
|
||||
label: t(`org:roles.${role}`),
|
||||
}))}
|
||||
/>
|
||||
|
||||
<TextFormField<FormFields>
|
||||
label={t("org:edit.form.members.roleDisplayName.title")}
|
||||
name={`members.${idx}.roleDisplayName` as const}
|
||||
/>
|
||||
</FormFieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesFormField() {
|
||||
const {
|
||||
formState: { errors },
|
||||
} = useFormContext<FormFields>();
|
||||
const { fields, append, remove } = useFieldArray<FormFields>({
|
||||
name: "series",
|
||||
});
|
||||
const { t } = useTranslation(["org"]);
|
||||
|
||||
const rootError = errors.series?.root;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{t("org:edit.form.series.title")}</Label>
|
||||
<div className="stack md">
|
||||
{fields.map((field, i) => {
|
||||
return <SeriesFieldset key={field.id} idx={i} remove={remove} />;
|
||||
})}
|
||||
<AddFieldButton
|
||||
onClick={() => {
|
||||
append({ description: "", name: "", showLeaderboard: false });
|
||||
}}
|
||||
/>
|
||||
{rootError && (
|
||||
<FormMessage type="error">{rootError.message as string}</FormMessage>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesFieldset({
|
||||
idx,
|
||||
remove,
|
||||
}: { idx: number; remove: (idx: number) => void }) {
|
||||
const { t } = useTranslation(["org", "common"]);
|
||||
const { clearErrors } = useFormContext<FormFields>();
|
||||
|
||||
return (
|
||||
<FormFieldset
|
||||
title={`#${idx + 1}`}
|
||||
onRemove={() => {
|
||||
remove(idx);
|
||||
clearErrors("series");
|
||||
}}
|
||||
>
|
||||
<TextFormField<FormFields>
|
||||
label={t("org:edit.form.series.seriesName.title")}
|
||||
name={`series.${idx}.name` as const}
|
||||
/>
|
||||
|
||||
<TextAreaFormField<FormFields>
|
||||
label={t("common:forms.description")}
|
||||
name={`series.${idx}.description` as const}
|
||||
maxLength={DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
|
||||
<ToggleFormField<FormFields>
|
||||
label={t("org:edit.form.series.showLeaderboard.title")}
|
||||
name={`series.${idx}.showLeaderboard` as const}
|
||||
/>
|
||||
</FormFieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgesFormField() {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const methods = useFormContext<FormFields>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{t("org:edit.form.badges.title")}</Label>
|
||||
<Controller
|
||||
control={methods.control}
|
||||
name="badges"
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<BadgesSelector
|
||||
selectedBadges={value}
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgesSelector({
|
||||
selectedBadges,
|
||||
onChange,
|
||||
onBlur,
|
||||
}: {
|
||||
selectedBadges: number[];
|
||||
onChange: (newBadges: number[]) => void;
|
||||
onBlur: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{selectedBadges.length > 0 ? (
|
||||
<BadgeDisplay
|
||||
badges={data.badgeOptions.filter((badge) =>
|
||||
selectedBadges.includes(badge.id),
|
||||
)}
|
||||
onBadgeRemove={(badgeId) =>
|
||||
onChange(selectedBadges.filter((id) => id !== badgeId))
|
||||
}
|
||||
key={selectedBadges.join(",")}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-lighter text-md font-bold">
|
||||
{t("org:edit.form.badges.none")}
|
||||
</div>
|
||||
)}
|
||||
<select
|
||||
onBlur={onBlur}
|
||||
onChange={(e) => onChange([Number(e.target.value), ...selectedBadges])}
|
||||
>
|
||||
<option>{t("org:edit.form.badges.select")}</option>
|
||||
{data.badgeOptions
|
||||
.filter((badge) => !selectedBadges.includes(badge.id))
|
||||
.map((badge) => (
|
||||
<option key={badge.id} value={badge.id}>
|
||||
{badge.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
568
app/features/tournament-organization/routes/org.$slug.tsx
Normal file
568
app/features/tournament-organization/routes/org.$slug.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { Main } from "~/components/Main";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
import { Pagination } from "~/components/Pagination";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import {
|
||||
BLANK_IMAGE_URL,
|
||||
calendarEventPage,
|
||||
tournamentOrganizationEditPage,
|
||||
tournamentOrganizationPage,
|
||||
tournamentPage,
|
||||
userPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { EventCalendar } from "../components/EventCalendar";
|
||||
import { SocialLinksList } from "../components/SocialLinksList";
|
||||
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "../tournament-organization-constants";
|
||||
import { canEditTournamentOrganization } from "../tournament-organization-utils";
|
||||
|
||||
import "../tournament-organization.css";
|
||||
|
||||
import { loader } from "../loaders/org.$slug.server";
|
||||
export { loader };
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader>;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
const title = makeTitle(data.organization.name);
|
||||
|
||||
return [
|
||||
{ title },
|
||||
{
|
||||
property: "og:title",
|
||||
content: title,
|
||||
},
|
||||
{
|
||||
property: "og:description",
|
||||
content: data.organization.description,
|
||||
},
|
||||
{
|
||||
property: "og:type",
|
||||
content: "website",
|
||||
},
|
||||
{
|
||||
property: "og:image",
|
||||
content: data.organization.avatarUrl
|
||||
? userSubmittedImage(data.organization.avatarUrl)
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: "twitter:card",
|
||||
content: "summary",
|
||||
},
|
||||
{
|
||||
name: "twitter:title",
|
||||
content: title,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["badges", "org"],
|
||||
breadcrumb: ({ match }) => {
|
||||
const data = match.data as SerializeFrom<typeof loader> | undefined;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
imgPath: data.organization.avatarUrl
|
||||
? userSubmittedImage(data.organization.avatarUrl)
|
||||
: BLANK_IMAGE_URL,
|
||||
href: tournamentOrganizationPage({
|
||||
organizationSlug: data.organization.slug,
|
||||
}),
|
||||
type: "IMAGE",
|
||||
text: data.organization.name,
|
||||
rounded: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default function TournamentOrganizationPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<LogoHeader />
|
||||
<InfoTabs />
|
||||
{data.organization.series.length > 0 ? (
|
||||
<SeriesSelector series={data.organization.series} />
|
||||
) : null}
|
||||
{data.series ? (
|
||||
<SeriesView series={data.series} />
|
||||
) : (
|
||||
<AllTournamentsView />
|
||||
)}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoHeader() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack horizontal md">
|
||||
<Avatar
|
||||
size="lg"
|
||||
url={
|
||||
data.organization.avatarUrl
|
||||
? userSubmittedImage(data.organization.avatarUrl)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="stack sm">
|
||||
<div className="text-xl font-bold">{data.organization.name}</div>
|
||||
{canEditTournamentOrganization({
|
||||
user,
|
||||
organization: data.organization,
|
||||
}) ? (
|
||||
<div className="stack items-start">
|
||||
<LinkButton
|
||||
to={tournamentOrganizationEditPage(data.organization.slug)}
|
||||
icon={<EditIcon />}
|
||||
size="tiny"
|
||||
variant="outlined"
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="whitespace-pre-wrap text-sm text-lighter">
|
||||
{data.organization.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTabs() {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NewTabs
|
||||
tabs={[
|
||||
{
|
||||
label: t("org:edit.form.socialLinks.title"),
|
||||
disabled:
|
||||
!data.organization.socials ||
|
||||
data.organization.socials.length === 0,
|
||||
},
|
||||
{
|
||||
label: t("org:edit.form.members.title"),
|
||||
},
|
||||
{
|
||||
label: t("org:edit.form.badges.title"),
|
||||
disabled: data.organization.badges.length === 0,
|
||||
},
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
element: (
|
||||
<SocialLinksList links={data.organization.socials ?? []} />
|
||||
),
|
||||
key: "socials",
|
||||
},
|
||||
{
|
||||
element: <MembersList />,
|
||||
key: "members",
|
||||
},
|
||||
{
|
||||
element: <BadgeDisplay badges={data.organization.badges} />,
|
||||
key: "badges",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MembersList() {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack sm text-sm">
|
||||
{data.organization.members.map((member) => {
|
||||
return (
|
||||
<Link
|
||||
key={member.id}
|
||||
to={userPage(member)}
|
||||
className="stack horizontal xs items-center text-main-forced w-max"
|
||||
>
|
||||
<Avatar user={member} size="xs" />
|
||||
<div>
|
||||
<div>{member.username}</div>
|
||||
<div className="text-lighter text-xs">
|
||||
{member.roleDisplayName ?? t(`org:roles.${member.role}`)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllTournamentsView() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="org__events-container">
|
||||
<EventCalendar
|
||||
month={data.month}
|
||||
year={data.year}
|
||||
events={data.events}
|
||||
fallbackLogoUrl={
|
||||
data.organization.avatarUrl
|
||||
? userSubmittedImage(data.organization.avatarUrl)
|
||||
: BLANK_IMAGE_URL
|
||||
}
|
||||
/>
|
||||
<EventsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesView({
|
||||
series,
|
||||
}: {
|
||||
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["org"]);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<SeriesHeader series={series} />
|
||||
<div>
|
||||
<NewTabs
|
||||
disappearing
|
||||
tabs={[
|
||||
{
|
||||
label: t("org:events.tabs.events"),
|
||||
number: series.eventsCount,
|
||||
},
|
||||
{
|
||||
label: t("org:events.tabs.leaderboard"),
|
||||
disabled: !series.leaderboard,
|
||||
},
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
key: "events",
|
||||
element: (
|
||||
<div className="stack lg">
|
||||
<EventsList showYear />
|
||||
<EventsPagination series={series} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "leaderboard",
|
||||
element: series.leaderboard && (
|
||||
<EventLeaderboard
|
||||
leaderboard={series.leaderboard}
|
||||
ownEntry={series.ownEntry}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesHeader({
|
||||
series,
|
||||
}: {
|
||||
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
|
||||
}) {
|
||||
const { i18n, t } = useTranslation(["org"]);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className="stack horizontal md items-center">
|
||||
{series.logoUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
src={series.logoUrl}
|
||||
width={64}
|
||||
height={64}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h2 className="text-lg">{series.name}</h2>
|
||||
{series.established ? (
|
||||
<div className="text-lighter text-italic text-xs">
|
||||
{t("org:events.established.short")}{" "}
|
||||
{databaseTimestampToDate(series.established).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap">{series.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesSelector({
|
||||
series,
|
||||
}: {
|
||||
series: SerializeFrom<typeof loader>["organization"]["series"];
|
||||
}) {
|
||||
const { t } = useTranslation(["org"]);
|
||||
|
||||
return (
|
||||
<div className="stack horizontal md flex-wrap">
|
||||
<SeriesButton>{t("org:events.all")}</SeriesButton>
|
||||
{series.map((series) => (
|
||||
<SeriesButton key={series.id} seriesId={series.id}>
|
||||
{series.name}
|
||||
</SeriesButton>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesButton({
|
||||
children,
|
||||
seriesId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
seriesId?: number;
|
||||
}) {
|
||||
return (
|
||||
<LinkButton
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
to={`?series=${seriesId ?? "all"}`}
|
||||
>
|
||||
{children}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsList({ showYear }: { showYear?: boolean }) {
|
||||
const { t } = useTranslation(["org"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const now = databaseTimestampNow();
|
||||
const pastEvents = data.events.filter((event) => event.startTime < now);
|
||||
const upcomingEvents = data.events.filter((event) => event.startTime >= now);
|
||||
|
||||
return (
|
||||
<div className="w-full stack xs">
|
||||
{upcomingEvents.length > 0 ? (
|
||||
<SectionDivider>{t("org:events.upcoming")}</SectionDivider>
|
||||
) : null}
|
||||
<div className="stack md">
|
||||
{upcomingEvents.map((event) => (
|
||||
<EventInfo key={event.eventId} event={event} showYear={showYear} />
|
||||
))}
|
||||
</div>
|
||||
{pastEvents.length > 0 ? (
|
||||
<SectionDivider>{t("org:events.past")}</SectionDivider>
|
||||
) : null}
|
||||
<div className="stack md">
|
||||
{pastEvents.map((event) => (
|
||||
<EventInfo key={event.eventId} event={event} showYear={showYear} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionDivider({ children }: { children: React.ReactNode }) {
|
||||
return <div className="org__section-divider">{children}</div>;
|
||||
}
|
||||
|
||||
function EventInfo({
|
||||
event,
|
||||
showYear,
|
||||
}: {
|
||||
event: SerializeFrom<typeof loader>["events"][number];
|
||||
showYear?: boolean;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<Link
|
||||
to={
|
||||
event.tournamentId
|
||||
? tournamentPage(event.tournamentId)
|
||||
: calendarEventPage(event.eventId)
|
||||
}
|
||||
className="org__event-info"
|
||||
>
|
||||
{event.logoUrl ? (
|
||||
<img src={event.logoUrl} alt={event.name} width={38} height={38} />
|
||||
) : null}
|
||||
<div>
|
||||
<div className="org__event-info__name">{event.name}</div>
|
||||
<time className="org__event-info__time">
|
||||
{databaseTimestampToDate(event.startTime).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: showYear ? "numeric" : undefined,
|
||||
},
|
||||
)}
|
||||
</time>
|
||||
</div>
|
||||
</Link>
|
||||
{event.tournamentWinners || event.eventWinners ? (
|
||||
<EventWinners winner={event.tournamentWinners ?? event.eventWinners!} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventWinners({
|
||||
winner,
|
||||
}: {
|
||||
winner: NonNullable<
|
||||
| SerializeFrom<typeof loader>["events"][number]["tournamentWinners"]
|
||||
| SerializeFrom<typeof loader>["events"][number]["eventWinners"]
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack xs">
|
||||
<div className="stack horizontal sm items-center font-semi-bold">
|
||||
<Placement placement={1} size={24} />
|
||||
{winner.avatarUrl ? (
|
||||
<img
|
||||
src={userSubmittedImage(winner.avatarUrl)}
|
||||
alt=""
|
||||
width={24}
|
||||
height={24}
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : null}
|
||||
{winner.name}
|
||||
</div>
|
||||
<div className="stack xs horizontal">
|
||||
{winner.members.map((member) => (
|
||||
<Avatar key={member.discordId} user={member} size="xxs" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsPagination({
|
||||
series,
|
||||
}: {
|
||||
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
|
||||
}) {
|
||||
if (!series.eventsCount) return null;
|
||||
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
|
||||
const setPage = (page: number) =>
|
||||
setSearchParams((prev) => {
|
||||
prev.set("page", String(page));
|
||||
|
||||
return prev;
|
||||
});
|
||||
|
||||
return (
|
||||
<Pagination
|
||||
currentPage={series.page}
|
||||
nextPage={() => setPage(series.page + 1)}
|
||||
pagesCount={Math.ceil(
|
||||
series.eventsCount / TOURNAMENT_SERIES_EVENTS_PER_PAGE,
|
||||
)}
|
||||
previousPage={() => setPage(series.page - 1)}
|
||||
setPage={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function EventLeaderboard({
|
||||
leaderboard,
|
||||
ownEntry,
|
||||
}: {
|
||||
leaderboard: NonNullable<
|
||||
NonNullable<SerializeFrom<typeof loader>["series"]>["leaderboard"]
|
||||
>;
|
||||
ownEntry?: NonNullable<SerializeFrom<typeof loader>["series"]>["ownEntry"];
|
||||
}) {
|
||||
return (
|
||||
<div className="stack md">
|
||||
{ownEntry ? (
|
||||
<>
|
||||
<ol className="org__leaderboard-list" start={ownEntry.placement}>
|
||||
<li>
|
||||
<EventLeaderboardRow entry={ownEntry.entry} />
|
||||
</li>
|
||||
</ol>
|
||||
<Divider />
|
||||
</>
|
||||
) : null}
|
||||
<ol className="org__leaderboard-list">
|
||||
{leaderboard.map((entry) => (
|
||||
<li key={entry.user.discordId}>
|
||||
<EventLeaderboardRow entry={entry} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventLeaderboardRow({
|
||||
entry,
|
||||
}: {
|
||||
entry: NonNullable<
|
||||
NonNullable<SerializeFrom<typeof loader>["series"]>["leaderboard"]
|
||||
>[number];
|
||||
}) {
|
||||
return (
|
||||
<div className="org__leaderboard-list__row">
|
||||
<Link
|
||||
to={userPage(entry.user)}
|
||||
className="stack horizontal sm items-center font-semi-bold text-main-forced"
|
||||
>
|
||||
<Avatar size="xs" user={entry.user} />
|
||||
{entry.user.username}
|
||||
</Link>
|
||||
<div className="stack sm horizontal items-center text-lighter font-semi-bold">
|
||||
<span className="text-main-forced">{entry.points}p</span>{" "}
|
||||
<Placement placement={1} /> ×{entry.placements.first}
|
||||
<Placement placement={2} /> ×{entry.placements.second}
|
||||
<Placement placement={3} /> ×{entry.placements.third}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export const TOURNAMENT_SERIES_EVENTS_PER_PAGE = 20;
|
||||
export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { notFoundIfFalsy, parseParams } from "~/utils/remix";
|
||||
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
|
||||
|
||||
const organizationParamsSchema = z.object({
|
||||
slug: z.string(),
|
||||
});
|
||||
|
||||
export async function organizationFromParams(
|
||||
params: LoaderFunctionArgs["params"],
|
||||
) {
|
||||
const { slug } = parseParams({ params, schema: organizationParamsSchema });
|
||||
return notFoundIfFalsy(
|
||||
await TournamentOrganizationRepository.findBySlug(slug),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { isAdmin } from "~/permissions";
|
||||
import type { UnwrappedNonNullable } from "~/utils/types";
|
||||
import type * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
|
||||
|
||||
export function canEditTournamentOrganization({
|
||||
user,
|
||||
organization,
|
||||
}: {
|
||||
user?: { id: number };
|
||||
organization: Pick<
|
||||
UnwrappedNonNullable<typeof TournamentOrganizationRepository.findBySlug>,
|
||||
"members"
|
||||
>;
|
||||
}) {
|
||||
if (isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return organization.members.some(
|
||||
(member) => member.id === user?.id && member.role === "ADMIN",
|
||||
);
|
||||
}
|
||||
183
app/features/tournament-organization/tournament-organization.css
Normal file
183
app/features/tournament-organization/tournament-organization.css
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
.org__calendar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, max-content);
|
||||
gap: var(--s-2);
|
||||
--cell-size: 55px;
|
||||
}
|
||||
|
||||
.org__calendar__container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
.org__calendar__day-header {
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.org__calendar__day {
|
||||
width: var(--cell-size);
|
||||
height: var(--cell-size);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
background-color: var(--bg-lighter);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.org__calendar__day__date {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 6px;
|
||||
}
|
||||
|
||||
.org__calendar__day__logo {
|
||||
position: absolute;
|
||||
border-radius: var(--rounded);
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
}
|
||||
|
||||
.org__calendar__day__many-events {
|
||||
position: absolute;
|
||||
border-radius: var(--rounded);
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid var(--border);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: var(--fonts-md);
|
||||
background-color: var(--bg-lightest);
|
||||
}
|
||||
|
||||
.org__calendar__day__today {
|
||||
color: var(--theme-secondary);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.org__calendar__day__previous {
|
||||
background-color: var(--bg-darker);
|
||||
}
|
||||
|
||||
.org__calendar__month-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.org__calendar__month-selector a {
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
|
||||
.org__calendar__month-selector > div {
|
||||
font-size: var(--fonts-lg);
|
||||
margin-block-start: var(--s-1);
|
||||
}
|
||||
|
||||
.org__section-divider {
|
||||
font-size: var(--fonts-md);
|
||||
font-weight: var(--bold);
|
||||
color: var(--text-lighter);
|
||||
border-bottom: 2px solid var(--border);
|
||||
width: 100%;
|
||||
padding-block: var(--s-1);
|
||||
}
|
||||
|
||||
.org__events-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-16);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.org__events-container {
|
||||
flex-direction: row;
|
||||
gap: var(--s-8);
|
||||
overflow-x: initial;
|
||||
}
|
||||
|
||||
.org__calendar__container {
|
||||
position: sticky;
|
||||
top: 47px;
|
||||
}
|
||||
}
|
||||
|
||||
.org__event-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text);
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.org__event-info img {
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.org__event-info__time {
|
||||
display: block;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.org__leaderboard-list {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.org__leaderboard-list li::marker {
|
||||
font-size: var(--fonts-lg);
|
||||
font-weight: var(--bold);
|
||||
color: var(--theme);
|
||||
padding-inline-end: var(--s-2);
|
||||
}
|
||||
|
||||
.org__leaderboard-list__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
padding-inline-start: var(--s-2-5);
|
||||
}
|
||||
|
||||
.org__social-link {
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text-main);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
align-items: center;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.org__social-link svg {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.org__social-link__icon-container {
|
||||
background-color: var(--bg-lightest);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
|
||||
.org__social-link__icon-container.twitch svg {
|
||||
fill: #9146ff;
|
||||
}
|
||||
|
||||
.org__social-link__icon-container.youtube svg {
|
||||
fill: #f00;
|
||||
}
|
||||
|
||||
.org__social-link__icon-container.twitter svg {
|
||||
fill: #1da1f2;
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.
|
|||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import {
|
||||
type SendouRouteHandle,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { tournamentSubsPage } from "~/utils/urls";
|
||||
|
|
@ -37,7 +37,7 @@ export const handle: SendouRouteHandle = {
|
|||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: subSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { getUser, requireUser } from "~/features/auth/core/user.server";
|
|||
import { tournamentIdFromParams } from "~/features/tournament";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { tournamentRegisterPage, userPage } from "~/utils/urls";
|
||||
import { deleteSub } from "../queries/deleteSub.server";
|
||||
|
|
@ -36,7 +36,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
const user = await requireUser(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: deleteSubSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,39 @@ export async function findById(id: number) {
|
|||
"CalendarEvent.name",
|
||||
"CalendarEvent.description",
|
||||
"CalendarEventDate.startTime",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("TournamentOrganization")
|
||||
.innerJoin(
|
||||
"UserSubmittedImage",
|
||||
"TournamentOrganization.avatarImgId",
|
||||
"UserSubmittedImage.id",
|
||||
)
|
||||
.select(({ eb: innerEb }) => [
|
||||
"TournamentOrganization.id",
|
||||
"TournamentOrganization.name",
|
||||
"TournamentOrganization.slug",
|
||||
"UserSubmittedImage.url as avatarUrl",
|
||||
jsonArrayFrom(
|
||||
innerEb
|
||||
.selectFrom("TournamentOrganizationMember")
|
||||
.select([
|
||||
"TournamentOrganizationMember.userId",
|
||||
"TournamentOrganizationMember.role",
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
"=",
|
||||
"TournamentOrganization.id",
|
||||
),
|
||||
).as("members"),
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentOrganization.id",
|
||||
"=",
|
||||
"CalendarEvent.organizationId",
|
||||
),
|
||||
).as("organization"),
|
||||
eb
|
||||
.selectFrom("UnvalidatedUserSubmittedImage")
|
||||
.select(["UnvalidatedUserSubmittedImage.url"])
|
||||
|
|
@ -314,6 +347,25 @@ export async function forShowcase() {
|
|||
return [latestWinners, ...next].filter(Boolean);
|
||||
}
|
||||
|
||||
export function topThreeResultsByTournamentId(tournamentId: number) {
|
||||
return db
|
||||
.selectFrom("TournamentResult")
|
||||
.select(({ eb }) => [
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select([...COMMON_USER_FIELDS])
|
||||
.whereRef("User.id", "=", "TournamentResult.userId"),
|
||||
).as("user"),
|
||||
])
|
||||
.where("tournamentId", "=", tournamentId)
|
||||
.where("TournamentResult.placement", "<=", 3)
|
||||
.$narrowType<{ user: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findCastTwitchAccountsByTournamentId(
|
||||
tournamentId: number,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import invariant from "~/utils/invariant";
|
|||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
|
@ -48,7 +48,7 @@ import { useTournament } from "./to.$id";
|
|||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { tournamentPage, userEditProfilePage } from "~/utils/urls";
|
||||
import { findByInviteCode } from "../queries/findTeamByInviteCode.server";
|
||||
|
|
@ -32,7 +32,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
const user = await requireUserId(request);
|
||||
const url = new URL(request.url);
|
||||
const inviteCode = url.searchParams.get("code");
|
||||
const data = await parseRequestFormData({ request, schema: joinSchema });
|
||||
const data = await parseRequestPayload({ request, schema: joinSchema });
|
||||
invariant(inviteCode, "code is missing");
|
||||
|
||||
const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode));
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
navIconUrl,
|
||||
readonlyMapsPage,
|
||||
tournamentJoinPage,
|
||||
tournamentOrganizationPage,
|
||||
tournamentSubsPage,
|
||||
userEditProfilePage,
|
||||
userPage,
|
||||
|
|
@ -86,7 +87,52 @@ export default function TournamentRegisterPage() {
|
|||
/>
|
||||
<div>
|
||||
<div className="tournament__title">{tournament.ctx.name}</div>
|
||||
<div className="stack horizontal sm">
|
||||
<div>
|
||||
{tournament.ctx.organization ? (
|
||||
<Link
|
||||
to={tournamentOrganizationPage({
|
||||
organizationSlug: tournament.ctx.organization.slug,
|
||||
tournamentName: tournament.ctx.name,
|
||||
})}
|
||||
className="stack horizontal sm items-center text-xs text-main-forced"
|
||||
>
|
||||
<Avatar
|
||||
url={
|
||||
tournament.ctx.organization.avatarUrl
|
||||
? userSubmittedImage(
|
||||
tournament.ctx.organization.avatarUrl,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
size="xxs"
|
||||
/>
|
||||
{tournament.ctx.organization.name}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={userPage(tournament.ctx.author)}
|
||||
className="stack horizontal xs items-center text-lighter"
|
||||
>
|
||||
<UserIcon className="tournament__info__icon" />{" "}
|
||||
{tournament.ctx.author.username}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="tournament__by mt-2">
|
||||
<div className="stack horizontal xs items-center">
|
||||
<ClockIcon className="tournament__info__icon" />{" "}
|
||||
{isMounted
|
||||
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
||||
timeZoneName: "short",
|
||||
minute: startsAtEvenHour ? undefined : "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal sm mt-1">
|
||||
{tournament.ranked ? (
|
||||
<div className="tournament__badge tournament__badge__ranked">
|
||||
Ranked
|
||||
|
|
@ -102,27 +148,6 @@ export default function TournamentRegisterPage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tournament__by mt-1">
|
||||
<Link
|
||||
to={userPage(tournament.ctx.author)}
|
||||
className="stack horizontal xs items-center text-lighter"
|
||||
>
|
||||
<UserIcon className="tournament__info__icon" />{" "}
|
||||
{tournament.ctx.author.username}
|
||||
</Link>
|
||||
<div className="stack horizontal xs items-center">
|
||||
<ClockIcon className="tournament__info__icon" />{" "}
|
||||
{isMounted
|
||||
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
||||
timeZoneName: "short",
|
||||
minute: startsAtEvenHour ? undefined : "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showAvatarPendingApprovalText ? (
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import {
|
|||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { useTimeoutState } from "~/hooks/useTimeoutState";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import {
|
||||
navIconUrl,
|
||||
tournamentBracketsPage,
|
||||
|
|
@ -58,7 +58,7 @@ import { tournamentIdFromParams } from "../tournament-utils";
|
|||
import { useTournament } from "./to.$id";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: seedsActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ import { databaseTimestampToDate } from "~/utils/dates";
|
|||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { tournamentPage, userSubmittedImage } from "~/utils/urls";
|
||||
import {
|
||||
tournamentOrganizationPage,
|
||||
tournamentPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { streamsByTournamentId } from "../core/streams.server";
|
||||
import {
|
||||
HACKY_resolvePicture,
|
||||
|
|
@ -93,16 +97,29 @@ export const handle: SendouRouteHandle = {
|
|||
if (!data) return [];
|
||||
|
||||
return [
|
||||
data.tournament.ctx.organization?.avatarUrl
|
||||
? {
|
||||
imgPath: userSubmittedImage(
|
||||
data.tournament.ctx.organization.avatarUrl,
|
||||
),
|
||||
href: tournamentOrganizationPage({
|
||||
organizationSlug: data.tournament.ctx.organization.slug,
|
||||
}),
|
||||
type: "IMAGE" as const,
|
||||
text: "",
|
||||
rounded: true,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
imgPath: data.tournament.ctx.logoUrl
|
||||
? userSubmittedImage(data.tournament.ctx.logoUrl)
|
||||
: HACKY_resolvePicture(data.tournament.ctx),
|
||||
href: tournamentPage(data.tournament.ctx.id),
|
||||
type: "IMAGE",
|
||||
type: "IMAGE" as const,
|
||||
text: data.tournament.ctx.name,
|
||||
rounded: true,
|
||||
},
|
||||
];
|
||||
].filter((crumb) => crumb !== null);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import type {
|
|||
} from "~/modules/in-game-lists/types";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import type { Nullish } from "~/utils/types";
|
||||
import { userBuildsPage } from "~/utils/urls";
|
||||
import {
|
||||
|
|
@ -40,7 +40,7 @@ import {
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: newBuildActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
|||
import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { userBuildsPage } from "~/utils/urls";
|
||||
import {
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: buildsActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import invariant from "~/utils/invariant";
|
|||
import {
|
||||
type SendouRouteHandle,
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { newArtPage } from "~/utils/urls";
|
||||
|
|
@ -31,7 +31,7 @@ export const handle: SendouRouteHandle = {
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: deleteArtSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { Link, useMatches } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { BattlefyIcon } from "~/components/icons/Battlefy";
|
||||
|
|
@ -11,11 +9,11 @@ import { DiscordIcon } from "~/components/icons/Discord";
|
|||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
import { TwitterIcon } from "~/components/icons/Twitter";
|
||||
import { YouTubeIcon } from "~/components/icons/YouTube";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { modesShort } from "~/modules/in-game-lists";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { rawSensToString } from "~/utils/strings";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
modeImageUrl,
|
||||
|
|
@ -24,7 +22,6 @@ import {
|
|||
topSearchPlayerPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { badgeExplanationText } from "../../badges/routes/badges.$id";
|
||||
import type { UserPageLoaderData } from "./u.$identifier";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
|
|
@ -67,7 +64,7 @@ export default function UserInfoPage() {
|
|||
<ExtraInfos />
|
||||
<WeaponPool />
|
||||
<TopPlacements />
|
||||
<BadgeContainer badges={data.badges} key={data.id} />
|
||||
<BadgeDisplay badges={data.badges} key={data.id} />
|
||||
{data.bio && <article>{data.bio}</article>}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -266,54 +263,3 @@ function TopPlacements() {
|
|||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeContainer(props: { badges: UserPageLoaderData["badges"] }) {
|
||||
const { t } = useTranslation("badges");
|
||||
const [badges, setBadges] = React.useState(props.badges);
|
||||
|
||||
const [bigBadge, ...smallBadges] = badges;
|
||||
if (!bigBadge) return null;
|
||||
|
||||
const setBadgeFirst = (badge: Unpacked<UserPageLoaderData["badges"]>) => {
|
||||
setBadges(
|
||||
badges.map((b, i) => {
|
||||
if (i === 0) return badge;
|
||||
if (b.code === badge.code) return badges[0];
|
||||
|
||||
return b;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={clsx("u__badges", {
|
||||
"justify-center": smallBadges.length === 0,
|
||||
})}
|
||||
>
|
||||
<Badge badge={bigBadge} size={125} isAnimated />
|
||||
{smallBadges.length > 0 ? (
|
||||
<div className="u__small-badges">
|
||||
{smallBadges.map((badge) => (
|
||||
<div key={badge.id} className="u__small-badge-container">
|
||||
<Badge
|
||||
badge={badge}
|
||||
onClick={() => setBadgeFirst(badge)}
|
||||
size={48}
|
||||
isAnimated
|
||||
/>
|
||||
{badge.count > 1 ? (
|
||||
<div className="u__small-badge-count">×{badge.count}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="u__badge-explanation">
|
||||
{badgeExplanationText(t, bigBadge)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import type { UserPageLoaderData } from "~/features/user-page/routes/u.$identifier";
|
||||
import { normalizeFormFieldArray } from "~/utils/arrays";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseRequestFormData } from "~/utils/remix";
|
||||
import { parseRequestPayload } from "~/utils/remix";
|
||||
import { userResultsPage } from "~/utils/urls";
|
||||
|
||||
const editHighlightsActionSchema = z.object({
|
||||
|
|
@ -28,7 +28,7 @@ const editHighlightsActionSchema = z.object({
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editHighlightsActionSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { secondsToMinutesNumberTuple } from "~/utils/number";
|
|||
import {
|
||||
type SendouRouteHandle,
|
||||
notFoundIfFalsy,
|
||||
parseRequestFormData,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix";
|
||||
import { VODS_PAGE, vodVideoPage } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
|
|
@ -49,7 +49,7 @@ export const handle: SendouRouteHandle = {
|
|||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: videoInputSchema,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
type ShouldRevalidateFunction,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
|
|
@ -24,7 +25,6 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor
|
|||
import { cache, ttl } from "~/utils/cache.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { Catcher } from "./components/Catcher";
|
||||
import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration";
|
||||
import { Layout } from "./components/layout";
|
||||
import {
|
||||
CUSTOMIZED_CSS_VARS_NAME,
|
||||
|
|
@ -184,7 +184,7 @@ function Document({
|
|||
{children}
|
||||
</Layout>
|
||||
</React.StrictMode>
|
||||
<ConditionalScrollRestoration />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -223,6 +223,7 @@ export const namespaceJsonsToPreloadObj: Record<
|
|||
art: true,
|
||||
q: true,
|
||||
lfg: true,
|
||||
org: true,
|
||||
};
|
||||
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);
|
||||
|
||||
|
|
|
|||
|
|
@ -432,6 +432,7 @@ dialog::backdrop {
|
|||
.button-text-paragraph {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.button-text-paragraph > button {
|
||||
|
|
@ -1826,3 +1827,48 @@ html[dir="rtl"] .fix-rtl {
|
|||
#nprogress .peg {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.badge-display__badges {
|
||||
display: flex;
|
||||
min-width: 20rem;
|
||||
max-width: 24rem;
|
||||
align-items: center;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-badge);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.badge-display__small-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.badge-display__badge-explanation {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.badge-display__small-badge-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.badge-display__small-badge-count {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: -8px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
color: var(--theme-vibrant);
|
||||
font-size: var(--fonts-xxxs);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,47 +119,6 @@
|
|||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.u__badges {
|
||||
display: flex;
|
||||
max-width: 24rem;
|
||||
align-items: center;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-badge);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.u__small-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.u__badge-explanation {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.u__small-badge-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.u__small-badge-count {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
margin-top: -8px;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
color: var(--theme-vibrant);
|
||||
font-size: var(--fonts-xxxs);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.u__results-table-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@
|
|||
width: max-content;
|
||||
}
|
||||
|
||||
.w-min {
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-inline: var(--s-4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getWeek } from "date-fns";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
|
||||
// TODO: when this lands https://github.com/remix-run/remix/discussions/7768 we can get rid of this (utilizing Kysely plugin to do converting from/to Date for us)
|
||||
export function databaseTimestampToDate(timestamp: number) {
|
||||
|
|
@ -137,3 +138,24 @@ export function getDateAtNextFullHour(date: Date) {
|
|||
export function dateToYYYYMMDD(date: Date) {
|
||||
return date.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// same as datesOfMonth but contains null at the start to start with monday
|
||||
export function nullPaddedDatesOfMonth({ month, year }: MonthYear) {
|
||||
const dates = datesOfMonth({ month, year });
|
||||
const firstDay = dates[0].getDay();
|
||||
const nulls = Array.from(
|
||||
{ length: firstDay === 0 ? 6 : firstDay - 1 },
|
||||
() => null,
|
||||
);
|
||||
return [...nulls, ...dates];
|
||||
}
|
||||
|
||||
function datesOfMonth({ month, year }: MonthYear) {
|
||||
const dates = [];
|
||||
const date = new Date(Date.UTC(year, month, 1));
|
||||
while (date.getMonth() === month) {
|
||||
dates.push(new Date(date));
|
||||
date.setDate(date.getDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
|
|
|||
21
app/utils/form.ts
Normal file
21
app/utils/form.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function valueArrayToDBFormat<T>(arr: Array<{ value?: T }>) {
|
||||
const unwrapped = arr
|
||||
.map((item) => {
|
||||
if (typeof item.value === "string" && item.value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return item.value;
|
||||
})
|
||||
.filter((item) => item !== null && item !== undefined);
|
||||
|
||||
return unwrapped.length === 0 ? null : unwrapped;
|
||||
}
|
||||
|
||||
export function wrapToValueStringArrayWithDefault(arr?: Array<string> | null) {
|
||||
return (
|
||||
arr?.map((value) => ({
|
||||
value,
|
||||
})) ?? [{ value: "" }]
|
||||
);
|
||||
}
|
||||
|
|
@ -26,16 +26,16 @@ export function notFoundIfNullLike<T>(value: T | null | undefined): T {
|
|||
return value;
|
||||
}
|
||||
|
||||
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) throw new Response(null, { status: 401 });
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) throw new Response(null, { status: 401 });
|
||||
export function badRequestIfFalsy<T>(value: T | null | undefined): T {
|
||||
if (!value) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
|
@ -75,7 +75,7 @@ export function parseSafeSearchParams<T extends z.ZodTypeAny>({
|
|||
}
|
||||
|
||||
/** Parse formData of a request with the given schema. Throws HTTP 400 response if fails. */
|
||||
export async function parseRequestFormData<T extends z.ZodTypeAny>({
|
||||
export async function parseRequestPayload<T extends z.ZodTypeAny>({
|
||||
request,
|
||||
schema,
|
||||
parseAsync,
|
||||
|
|
@ -84,7 +84,10 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
|
|||
schema: T;
|
||||
parseAsync?: boolean;
|
||||
}): Promise<z.infer<T>> {
|
||||
const formDataObj = formDataToObject(await request.formData());
|
||||
const formDataObj =
|
||||
request.headers.get("Content-Type") === "application/json"
|
||||
? await request.json()
|
||||
: formDataToObject(await request.formData());
|
||||
try {
|
||||
const parsed = parseAsync
|
||||
? await schema.parseAsync(formDataObj)
|
||||
|
|
@ -130,7 +133,7 @@ export async function parseFormData<T extends z.ZodTypeAny>({
|
|||
}
|
||||
}
|
||||
|
||||
/** Parse params with the given schema. Throws HTTP 400 response if fails. */
|
||||
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
|
||||
export function parseParams<T extends z.ZodTypeAny>({
|
||||
params,
|
||||
schema,
|
||||
|
|
@ -138,17 +141,12 @@ export function parseParams<T extends z.ZodTypeAny>({
|
|||
params: Params<string>;
|
||||
schema: T;
|
||||
}): z.infer<T> {
|
||||
try {
|
||||
return schema.parse(params);
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
noticeError(e, { params: JSON.stringify(params) });
|
||||
console.error(e);
|
||||
throw new Response(JSON.stringify(e), { status: 400 });
|
||||
}
|
||||
|
||||
throw e;
|
||||
const parsed = schema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function safeParseRequestFormData<T extends z.ZodTypeAny>({
|
||||
|
|
@ -212,6 +210,18 @@ export function validate(
|
|||
);
|
||||
}
|
||||
|
||||
export type ActionError = { field: string; msg: string; isError: true };
|
||||
|
||||
export function actionError<T extends z.ZodTypeAny>({
|
||||
msg,
|
||||
field,
|
||||
}: {
|
||||
msg: string;
|
||||
field: (keyof z.infer<T> & string) | `${keyof z.infer<T> & string}.root`;
|
||||
}): ActionError {
|
||||
return { msg, field, isError: true };
|
||||
}
|
||||
|
||||
export type Breadcrumb =
|
||||
| {
|
||||
imgPath: string;
|
||||
|
|
|
|||
|
|
@ -21,3 +21,7 @@ export type Nullish<T> = T | null | undefined;
|
|||
export type Unwrapped<T extends (...args: any) => any> = Unpacked<
|
||||
Awaited<ReturnType<T>>
|
||||
>;
|
||||
|
||||
export type UnwrappedNonNullable<T extends (...args: any) => any> = NonNullable<
|
||||
Unwrapped<T>
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import type {
|
|||
import type { ArtSource } from "~/features/art/art-types";
|
||||
import type { AuthErrorCode } from "~/features/auth/core/errors";
|
||||
import { serializeBuild } from "~/features/build-analyzer";
|
||||
import type { ImageUploadType } from "~/features/img-upload";
|
||||
import type { StageBackgroundStyle } from "~/features/map-planner";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
|
||||
|
|
@ -41,7 +40,9 @@ const staticAssetsUrl = ({
|
|||
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT =
|
||||
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
process.env.NODE_ENV === "development"
|
||||
? "https://sendou-test.ams3.digitaloceanspaces.com"
|
||||
: "https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
export const userSubmittedImage = (fileName: string) =>
|
||||
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;
|
||||
// images with https are not hosted on spaces, this is used for local development
|
||||
|
|
@ -292,6 +293,14 @@ export const tournamentStreamsPage = (tournamentId: number) => {
|
|||
return `/to/${tournamentId}/streams`;
|
||||
};
|
||||
|
||||
export const tournamentOrganizationPage = ({
|
||||
organizationSlug,
|
||||
tournamentName,
|
||||
}: { organizationSlug: string; tournamentName?: string }) =>
|
||||
`/org/${organizationSlug}${tournamentName ? `?source=${decodeURIComponent(tournamentName)}` : ""}`;
|
||||
export const tournamentOrganizationEditPage = (organizationSlug: string) =>
|
||||
`${tournamentOrganizationPage({ organizationSlug })}/edit`;
|
||||
|
||||
export const sendouQInviteLink = (inviteCode: string) =>
|
||||
`${SENDOUQ_PAGE}?${JOIN_CODE_SEARCH_PARAM_KEY}=${inviteCode}`;
|
||||
|
||||
|
|
@ -334,8 +343,14 @@ export const objectDamageCalculatorPage = (weaponId?: MainWeaponId) =>
|
|||
typeof weaponId === "number" ? `?weapon=${weaponId}` : ""
|
||||
}`;
|
||||
|
||||
export const uploadImagePage = (type: ImageUploadType) =>
|
||||
`/upload?type=${type}`;
|
||||
export const uploadImagePage = (
|
||||
args:
|
||||
| { type: "team-pfp" | "team-banner" }
|
||||
| { type: "org-pfp"; slug: string },
|
||||
) =>
|
||||
args.type === "org-pfp"
|
||||
? `/upload?type=${args.type}&slug=${args.slug}`
|
||||
: `/upload?type=${args.type}`;
|
||||
|
||||
export const vodVideoPage = (videoId: number) => `${VODS_PAGE}/${videoId}`;
|
||||
|
||||
|
|
|
|||
1
locales/da/org.json
Normal file
1
locales/da/org.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
1
locales/de/org.json
Normal file
1
locales/de/org.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
|
|
@ -157,6 +157,7 @@
|
|||
"upload.title": "Uploading {{type}}. Recommended size is {{width}}×{{height}}.",
|
||||
"upload.type.team-pfp": "team profile picture",
|
||||
"upload.type.team-banner": "team picture banner",
|
||||
"upload.type.org-pfp": "tournament organization profile picture",
|
||||
"upload.commonExplanation": "Before the image is publicly displayed a moderator will validate it. Images uploaded by patrons are shown without validation.",
|
||||
"upload.afterExplanation_one": "You have {{count}} image pending. The image will show up automatically after validation.",
|
||||
"upload.afterExplanation_other": "You have {{count}} images pending. The images will show up automatically after validation.",
|
||||
|
|
|
|||
26
locales/en/org.json
Normal file
26
locales/en/org.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"roles.ADMIN": "Admin",
|
||||
"roles.MEMBER": "Member",
|
||||
"roles.ORGANIZER": "Organizer",
|
||||
"roles.STREAMER": "Streamer",
|
||||
"events.established.short": "Est.",
|
||||
"events.all": "All tournaments",
|
||||
"events.past": "Past events",
|
||||
"events.upcoming": "Upcoming events",
|
||||
"events.tabs.events": "Events",
|
||||
"events.tabs.leaderboard": "Leaderboard",
|
||||
"edit.form.title": "Editing tournament organization",
|
||||
"edit.form.uploadLogo": "Upload logo",
|
||||
"edit.form.socialLinks.title": "Social links",
|
||||
"edit.form.members.title": "Members",
|
||||
"edit.form.members.user.title": "User",
|
||||
"edit.form.members.role.title": "Role",
|
||||
"edit.form.members.roleDisplayName.title": "Role display name",
|
||||
"edit.form.series.title": "Series",
|
||||
"edit.form.series.seriesName.title": "Series name",
|
||||
"edit.form.series.showLeaderboard.title": "Show leaderboard",
|
||||
"edit.form.badges.title": "Badges",
|
||||
"edit.form.badges.none": "No badges selected",
|
||||
"edit.form.badges.select": "Select badge to add",
|
||||
"edit.form.errors.noUnadmin": "Can't remove yourself as an admin"
|
||||
}
|
||||
1
locales/es-ES/org.json
Normal file
1
locales/es-ES/org.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
1
locales/es-US/org.json
Normal file
1
locales/es-US/org.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
1
locales/fr-CA/org.json
Normal file
1
locales/fr-CA/org.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user