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:
Kalle 2024-07-25 23:06:29 +03:00 committed by GitHub
parent 7c1c0544fb
commit 9312fad90f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
117 changed files with 3688 additions and 494 deletions

View File

@ -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, {

View File

@ -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} />;
}

View File

@ -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;

View File

@ -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 && (

View File

@ -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>
);
},
);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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;

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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")

View 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("&#39;", "'");
}

View 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>
);
}

View File

@ -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,
});

View File

@ -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("&#39;", "'");
}

View File

@ -63,7 +63,7 @@ export function useAnalyzeBuild() {
effect: newEffects,
focused: String(newFocused),
},
{ replace: true, state: { scroll: false } },
{ replace: true, preventScrollReset: true },
);
};

View File

@ -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>

View File

@ -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")

View File

@ -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)

View File

@ -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,
),
});
};

View File

@ -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(

View File

@ -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}

View 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;
}

View File

@ -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;

View File

@ -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,
});

View File

@ -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>

View File

@ -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%" },
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});
};

View File

@ -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 },
);
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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>

View File

@ -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,
});

View File

@ -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;
}
}

View File

@ -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: [

View File

@ -51,6 +51,7 @@ export const testTournament = (
eventId: 1,
id: 1,
description: null,
organization: null,
rules: null,
logoUrl: null,
logoSrc: "/test.png",

View File

@ -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) {

View File

@ -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,
});

View File

@ -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;
});
}

View File

@ -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 }),
);
};

View File

@ -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>
);
}

View File

@ -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;
};

View 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;
}
}

View File

@ -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),
),
};
}

View 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,
};
}

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,2 @@
export const TOURNAMENT_SERIES_EVENTS_PER_PAGE = 20;
export const TOURNAMENT_SERIES_LEADERBOARD_SIZE = 50;

View File

@ -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),
);
}

View File

@ -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",
);
}

View 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;
}

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
) {

View File

@ -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,
});

View File

@ -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));

View File

@ -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 ? (

View File

@ -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,
});

View File

@ -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);
},
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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>
);
}

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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);

View File

@ -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);
}

View File

@ -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;

View File

@ -154,6 +154,10 @@
width: max-content;
}
.w-min {
width: min-content;
}
.px-4 {
padding-inline: var(--s-4);
}

View File

@ -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
View 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: "" }]
);
}

View File

@ -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;

View File

@ -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>
>;

View File

@ -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
View File

@ -0,0 +1 @@
{}

1
locales/de/org.json Normal file
View File

@ -0,0 +1 @@
{}

View File

@ -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
View 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
View File

@ -0,0 +1 @@
{}

1
locales/es-US/org.json Normal file
View File

@ -0,0 +1 @@
{}

1
locales/fr-CA/org.json Normal file
View File

@ -0,0 +1 @@
{}

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