mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 02:55:52 -05:00
Merge branch 'main' into test-refactor
This commit is contained in:
@@ -1,13 +1,8 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Image } from "~/components/Image";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { addFriendCodeSchema } from "~/features/sendouq/q-schemas";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls";
|
||||
|
||||
const FC_INFO_IMAGE_URL = navIconUrl("fc-info");
|
||||
@@ -17,60 +12,39 @@ export function FriendCodeInput({
|
||||
}: {
|
||||
friendCode?: string | null;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const id = React.useId();
|
||||
|
||||
if (friendCode) {
|
||||
return <div className="font-bold text-center">SW-{friendCode}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PAGE}>
|
||||
<input type="hidden" name="revalidateRoot" value="true" />
|
||||
<div
|
||||
className={clsx("stack sm horizontal items-end", {
|
||||
"justify-center": friendCode,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{!friendCode ? (
|
||||
<div className="stack horizontal xs items-center">
|
||||
<Label htmlFor={id}>{t("common:fc.title")}</Label>
|
||||
<InfoPopover tiny>
|
||||
<div className="stack sm">
|
||||
<div className="text-xs font-bold">
|
||||
{t("common:fc.whereToFind")}
|
||||
</div>
|
||||
<Image
|
||||
path={FC_INFO_IMAGE_URL}
|
||||
alt={t("common:fc.whereToFind")}
|
||||
width={320}
|
||||
/>
|
||||
<SendouForm
|
||||
schema={addFriendCodeSchema}
|
||||
action={SENDOUQ_PAGE}
|
||||
revalidateRoot
|
||||
submitButtonText={t("common:actions.save")}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<div className="stack sm">
|
||||
<FormField name="friendCode" />
|
||||
<div className="stack horizontal sm items-center text-lighter text-xs">
|
||||
{t("common:fc.onceSetStaffOnly")}
|
||||
<InfoPopover tiny>
|
||||
<div className="stack sm">
|
||||
<div className="text-xs font-bold">
|
||||
{t("common:fc.whereToFind")}
|
||||
</div>
|
||||
</InfoPopover>
|
||||
</div>
|
||||
) : null}
|
||||
{friendCode ? (
|
||||
<div className="font-bold">SW-{friendCode}</div>
|
||||
) : (
|
||||
<Input
|
||||
leftAddon="SW-"
|
||||
id={id}
|
||||
name="friendCode"
|
||||
pattern={FRIEND_CODE_REGEXP_PATTERN}
|
||||
placeholder="1234-5678-9012"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
path={FC_INFO_IMAGE_URL}
|
||||
alt={t("common:fc.whereToFind")}
|
||||
width={320}
|
||||
/>
|
||||
</div>
|
||||
</InfoPopover>
|
||||
</div>
|
||||
</div>
|
||||
{!friendCode ? (
|
||||
<SubmitButton _action="ADD_FRIEND_CODE" state={fetcher.state}>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</div>
|
||||
{!friendCode ? (
|
||||
<div className="text-lighter text-xs mt-2">
|
||||
{t("common:fc.onceSetStaffOnly")}
|
||||
</div>
|
||||
) : null}
|
||||
</fetcher.Form>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import * as React from "react";
|
||||
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
import { FriendMenu } from "~/features/friends/components/FriendMenu";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
EVENTS_PAGE,
|
||||
FRIENDS_PAGE,
|
||||
navIconUrl,
|
||||
SENDOU_INK_BASE_URL,
|
||||
SETTINGS_PAGE,
|
||||
SUPPORT_PAGE,
|
||||
userPage,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
import { navItems } from "./layout/nav-items";
|
||||
import styles from "./MobileNav.module.css";
|
||||
import { NotificationDot } from "./NotificationDot";
|
||||
import { ShareUrlButton } from "./ShareUrlButton";
|
||||
import { StreamListItems } from "./StreamListItems";
|
||||
|
||||
type SidebarData = RootLoaderData["sidebar"] | undefined;
|
||||
@@ -346,6 +348,7 @@ function MenuOverlay({
|
||||
}) {
|
||||
const { t } = useTranslation(["front", "common"]);
|
||||
const user = useUser();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
@@ -377,6 +380,11 @@ function MenuOverlay({
|
||||
{t("common:pages.support")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<ShareUrlButton
|
||||
variant="minimal"
|
||||
shape="square"
|
||||
url={`${SENDOU_INK_BASE_URL}${location.pathname}${location.search}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.panelCloseButton}
|
||||
|
||||
44
app/components/ShareUrlButton.tsx
Normal file
44
app/components/ShareUrlButton.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Share2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { CopyToClipboardPopover } from "./CopyToClipboardPopover";
|
||||
|
||||
export function ShareUrlButton({
|
||||
url,
|
||||
...buttonProps
|
||||
}: { url: string } & React.ComponentProps<typeof SendouButton>) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const canNativeShare =
|
||||
typeof navigator !== "undefined" && typeof navigator.share === "function";
|
||||
|
||||
if (canNativeShare) {
|
||||
return (
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<Share2 />}
|
||||
onPress={() => navigator.share({ url })}
|
||||
aria-label={t("common:actions.share")}
|
||||
{...buttonProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboardPopover
|
||||
url={url}
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<Share2 />}
|
||||
aria-label={t("common:actions.share")}
|
||||
{...buttonProps}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -268,6 +268,7 @@
|
||||
.listLinkSubtitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
width: 100%;
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface StageSelectProps<Clearable extends boolean | undefined = undefined> {
|
||||
clearable?: Clearable;
|
||||
testId?: string;
|
||||
isRequired?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function StageSelect<Clearable extends boolean | undefined = undefined>({
|
||||
@@ -26,6 +27,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
|
||||
clearable,
|
||||
testId = "stage-select",
|
||||
isRequired,
|
||||
isDisabled,
|
||||
}: StageSelectProps<Clearable>) {
|
||||
const { t } = useTranslation(["common", "game-misc"]);
|
||||
const items = useStageItems();
|
||||
@@ -54,6 +56,7 @@ export function StageSelect<Clearable extends boolean | undefined = undefined>({
|
||||
clearable={clearable}
|
||||
data-testid={testId}
|
||||
isRequired={isRequired}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{({ id, name }) => (
|
||||
<SendouSelectItem key={id} id={id} textValue={name}>
|
||||
|
||||
@@ -1,21 +1,43 @@
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
|
||||
const FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
month: "numeric",
|
||||
year: "2-digit",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
};
|
||||
|
||||
const CLASS_NAME = "text-lighter font-semi-bold";
|
||||
|
||||
interface MatchBannerStartedAtProps {
|
||||
time: Date;
|
||||
/** When given, the time the match ended, shown as a range together with the start time */
|
||||
endTime?: Date | null;
|
||||
}
|
||||
|
||||
export function MatchBannerStartedAt({ time }: MatchBannerStartedAtProps) {
|
||||
export function MatchBannerStartedAt({
|
||||
time,
|
||||
endTime,
|
||||
}: MatchBannerStartedAtProps) {
|
||||
if (endTime) {
|
||||
return (
|
||||
<LocaleTimeRange
|
||||
from={time}
|
||||
to={endTime}
|
||||
options={FORMAT_OPTIONS}
|
||||
className={CLASS_NAME}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocaleTime
|
||||
date={time}
|
||||
options={{
|
||||
month: "numeric",
|
||||
year: "2-digit",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
}}
|
||||
className="text-lighter font-semi-bold"
|
||||
options={FORMAT_OPTIONS}
|
||||
className={CLASS_NAME}
|
||||
inline
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ function TimelineHeader({
|
||||
) : null}
|
||||
{isOngoing ? (
|
||||
<span className={styles.headerScoreLive}>
|
||||
{t("q:match.timeline.live")}
|
||||
{t("q:match.timeline.ongoing")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { refreshBannedCache } from "~/features/ban/core/banned.server";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToast,
|
||||
notFoundIfNullish,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { _action, actualNumber, friendCode } from "~/utils/zod";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { adminActionSchema } from "../admin-schemas";
|
||||
import {
|
||||
sendUserBannedWebhook,
|
||||
sendUserUnbannedWebhook,
|
||||
@@ -22,10 +22,16 @@ import {
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: adminActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const user = requireUser();
|
||||
|
||||
let message: string;
|
||||
@@ -35,8 +41,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const errorMessage = await AdminRepository.migrate({
|
||||
oldUserId: data["old-user"],
|
||||
newUserId: data["new-user"],
|
||||
oldUserId: data.oldUser,
|
||||
newUserId: data.newUser,
|
||||
});
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -75,8 +81,8 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
await AdminRepository.forcePatron({
|
||||
id: data.user,
|
||||
patronStartedAt: new Date(),
|
||||
patronTier: data.patronTier,
|
||||
patronExpiresAt: new Date(data.patronExpiresAt),
|
||||
patronTier: Number(data.patronTier),
|
||||
patronExpiresAt: data.patronExpiresAt,
|
||||
});
|
||||
|
||||
message = "Patron status updated";
|
||||
@@ -123,7 +129,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const bannedUser = notFoundIfNullish(
|
||||
await UserRepository.findLeanById(data.user),
|
||||
);
|
||||
const banExpiresAt = data.duration ? new Date(data.duration) : null;
|
||||
const banExpiresAt = data.expiresAt ?? null;
|
||||
|
||||
await AdminRepository.banUser({
|
||||
bannedReason: data.reason ?? null,
|
||||
@@ -170,7 +176,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
requireRole("STAFF");
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
friendCode: data.friendCode,
|
||||
friendCode: normalizeFriendCode(data.friendCode),
|
||||
submitterUserId: user.id,
|
||||
userId: data.user,
|
||||
});
|
||||
@@ -193,56 +199,3 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
return successToast(message);
|
||||
};
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("MIGRATE"),
|
||||
"old-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
"new-user": z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("REFRESH"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("FORCE_PATRON"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
patronTier: z.preprocess(actualNumber, z.number()),
|
||||
patronExpiresAt: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("VIDEO_ADDER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("TOURNAMENT_ORGANIZER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ARTIST"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("LINK_PLAYER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
playerId: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("BAN_USER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
reason: z.string().nullish(),
|
||||
duration: z.string().nullish(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UNBAN_USER"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_FRIEND_CODE"),
|
||||
friendCode,
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("API_ACCESS"),
|
||||
user: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const BAN_REASON_MAX_LENGTH = 200;
|
||||
|
||||
export const ADMIN_DISCORD_ID = "79237403620945920";
|
||||
export const ADMIN_ID = process.env.NODE_ENV === "test" ? 1 : 274;
|
||||
|
||||
|
||||
@@ -1,26 +1,132 @@
|
||||
import { z } from "zod";
|
||||
import { friendCodeField } from "~/features/sendouq/q-schemas";
|
||||
import {
|
||||
datetimeRequired,
|
||||
datetime,
|
||||
datetimeOptional,
|
||||
image,
|
||||
numberField,
|
||||
select,
|
||||
stringConstant,
|
||||
textFieldRequired,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { friendCode, id } from "~/utils/zod";
|
||||
import { BAN_REASON_MAX_LENGTH } from "./admin-constants";
|
||||
|
||||
export const adminActionSearchParamsSchema = z.object({
|
||||
friendCode,
|
||||
});
|
||||
|
||||
const userField = userSearch({ label: "labels.user" });
|
||||
|
||||
export const friendCodeSearchSchema = z.object({
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const migrateUserSchema = z.object({
|
||||
_action: stringConstant("MIGRATE"),
|
||||
oldUser: userSearch({ label: "labels.adminOldUser" }),
|
||||
newUser: userSearch({
|
||||
label: "labels.adminNewUser",
|
||||
bottomText: "bottomTexts.adminMigrateNewUser",
|
||||
}),
|
||||
});
|
||||
|
||||
export const linkPlayerSchema = z.object({
|
||||
_action: stringConstant("LINK_PLAYER"),
|
||||
user: userField,
|
||||
playerId: numberField({ label: "labels.adminPlayerId", min: 1 }),
|
||||
});
|
||||
|
||||
export const giveArtistSchema = z.object({
|
||||
_action: stringConstant("ARTIST"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveVideoAdderSchema = z.object({
|
||||
_action: stringConstant("VIDEO_ADDER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveTournamentOrganizerSchema = z.object({
|
||||
_action: stringConstant("TOURNAMENT_ORGANIZER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveApiAccessSchema = z.object({
|
||||
_action: stringConstant("API_ACCESS"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const updateFriendCodeSchema = z.object({
|
||||
_action: stringConstant("UPDATE_FRIEND_CODE"),
|
||||
user: userField,
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const forcePatronSchema = z.object({
|
||||
_action: stringConstant("FORCE_PATRON"),
|
||||
user: userField,
|
||||
patronTier: select({
|
||||
label: "labels.patronTier",
|
||||
items: [
|
||||
{ value: "1", label: "options.patronTier.1" },
|
||||
{ value: "2", label: "options.patronTier.2" },
|
||||
{ value: "3", label: "options.patronTier.3" },
|
||||
],
|
||||
}),
|
||||
patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }),
|
||||
});
|
||||
|
||||
export const banUserSchema = z.object({
|
||||
_action: stringConstant("BAN_USER"),
|
||||
user: userField,
|
||||
expiresAt: datetimeOptional({
|
||||
label: "labels.banUserExpiresAt",
|
||||
bottomText: "bottomTexts.banUserExpiresAtHelp",
|
||||
min: () => new Date(),
|
||||
minMessage: "errors.dateInPast",
|
||||
}),
|
||||
reason: textFieldOptional({
|
||||
label: "labels.reason",
|
||||
maxLength: BAN_REASON_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const unbanUserSchema = z.object({
|
||||
_action: stringConstant("UNBAN_USER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const refreshPlusTiersSchema = z.object({
|
||||
_action: stringConstant("REFRESH"),
|
||||
});
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
migrateUserSchema,
|
||||
linkPlayerSchema,
|
||||
giveArtistSchema,
|
||||
giveVideoAdderSchema,
|
||||
giveTournamentOrganizerSchema,
|
||||
giveApiAccessSchema,
|
||||
updateFriendCodeSchema,
|
||||
forcePatronSchema,
|
||||
banUserSchema,
|
||||
unbanUserSchema,
|
||||
refreshPlusTiersSchema,
|
||||
]);
|
||||
|
||||
export const createExternalStreamSchema = z.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 64 }),
|
||||
url: textFieldRequired({
|
||||
name: textField({ label: "labels.name", maxLength: 64 }),
|
||||
url: textField({
|
||||
label: "labels.link",
|
||||
maxLength: 200,
|
||||
validate: "url",
|
||||
}),
|
||||
avatar: image({ label: "labels.logo", autoValidate: true }),
|
||||
startTime: datetimeRequired({ label: "labels.startTime" }),
|
||||
startTime: datetime({ label: "labels.startTime" }),
|
||||
});
|
||||
|
||||
const deleteExternalStreamSchema = z.object({
|
||||
|
||||
@@ -10,10 +10,13 @@ import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/le
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
import type { adminActionSchema } from "../actions/admin.server";
|
||||
import type { adminActionSchema } from "../admin-schemas";
|
||||
import { action } from "./admin";
|
||||
|
||||
const adminAction = wrappedAction<typeof adminActionSchema>({ action });
|
||||
const adminAction = wrappedAction<typeof adminActionSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
@@ -260,8 +263,8 @@ const migrateUserAction = () =>
|
||||
adminAction(
|
||||
{
|
||||
_action: "MIGRATE",
|
||||
"old-user": users.id(1),
|
||||
"new-user": users.id(2),
|
||||
oldUser: users.id(1),
|
||||
newUser: users.id(2),
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import {
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
Link,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
@@ -19,11 +17,9 @@ import {
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Input } from "~/components/Input";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import {
|
||||
@@ -33,6 +29,20 @@ import {
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/admin.server";
|
||||
import {
|
||||
banUserSchema,
|
||||
forcePatronSchema,
|
||||
friendCodeSearchSchema,
|
||||
giveApiAccessSchema,
|
||||
giveArtistSchema,
|
||||
giveTournamentOrganizerSchema,
|
||||
giveVideoAdderSchema,
|
||||
linkPlayerSchema,
|
||||
migrateUserSchema,
|
||||
refreshPlusTiersSchema,
|
||||
unbanUserSchema,
|
||||
updateFriendCodeSchema,
|
||||
} from "../admin-schemas";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
|
||||
import { loader } from "../loaders/admin.server";
|
||||
|
||||
@@ -59,7 +69,7 @@ export default function AdminPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<Main halfWidth>
|
||||
<SendouTabs>
|
||||
<SendouTabList>
|
||||
<SendouTab id="actions">Actions</SendouTab>
|
||||
@@ -79,28 +89,17 @@ export default function AdminPage() {
|
||||
function FriendCodeLookUp() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [friendCode, setFriendCode] = React.useState(
|
||||
searchParams.get("friendCode") ?? "",
|
||||
);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack md horizontal justify-center">
|
||||
<Input
|
||||
placeholder="1234-5678-9101"
|
||||
name="friendCode"
|
||||
value={friendCode}
|
||||
onChange={(e) => setFriendCode(e.target.value)}
|
||||
/>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
icon={<Search />}
|
||||
onPress={() => setSearchParams({ friendCode })}
|
||||
>
|
||||
Search
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<div className="stack lg">
|
||||
<SendouForm
|
||||
schema={friendCodeSearchSchema}
|
||||
defaultValues={{ friendCode: searchParams.get("friendCode") ?? "" }}
|
||||
submitButtonText="Search"
|
||||
onApply={({ friendCode }) => setSearchParams({ friendCode })}
|
||||
>
|
||||
{({ FormField }) => <FormField name="friendCode" />}
|
||||
</SendouForm>
|
||||
<div className="stack lg">
|
||||
{data.friendCodeSearchUsers?.map((user) => (
|
||||
<Link
|
||||
@@ -175,272 +174,157 @@ function Impersonate() {
|
||||
}
|
||||
|
||||
function MigrateUser() {
|
||||
const [oldUserId, setOldUserId] = React.useState<number>();
|
||||
const [newUserId, setNewUserId] = React.useState<number>();
|
||||
const navigation = useNavigation();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Migrate user data</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch
|
||||
label="Old user"
|
||||
name="old-user"
|
||||
onChange={(newUser) => setOldUserId(newUser?.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-same-size">
|
||||
<UserSearch
|
||||
label="New user"
|
||||
name="new-user"
|
||||
onChange={(newUser) => setNewUserId(newUser?.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
isDisabled={!oldUserId || !newUserId || navigation.state !== "idle"}
|
||||
_action="MIGRATE"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Migrate
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<FormMessage type="info">
|
||||
Note: data on "New user" will be deleted (e.g. builds)
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={migrateUserSchema}
|
||||
title="Migrate user data"
|
||||
submitButtonText="Migrate"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="oldUser" />
|
||||
<FormField name="newUser" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPlayer() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Link player</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="flex-same-size">
|
||||
<label>Player ID</label>
|
||||
<input type="number" name="playerId" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="LINK_PLAYER" state={fetcher.state}>
|
||||
Link player
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={linkPlayerSchema}
|
||||
title="Link player"
|
||||
submitButtonText="Link player"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="playerId" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveArtist() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Add as artist</h2>
|
||||
<div className="stack horizontal md">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="ARTIST" state={fetcher.state}>
|
||||
Add as artist
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={giveArtistSchema}
|
||||
title="Add as artist"
|
||||
submitButtonText="Add as artist"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveVideoAdder() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Give video adder</h2>
|
||||
<div className="stack horizontal md">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="VIDEO_ADDER" state={fetcher.state}>
|
||||
Add as video adder
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={giveVideoAdderSchema}
|
||||
title="Give video adder"
|
||||
submitButtonText="Add as video adder"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveTournamentOrganizer() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Give tournament organizer</h2>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
_action="TOURNAMENT_ORGANIZER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Add as tournament organizer
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={giveTournamentOrganizerSchema}
|
||||
title="Give tournament organizer"
|
||||
submitButtonText="Add as tournament organizer"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function GiveApiAccess() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Give API access</h2>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="API_ACCESS" state={fetcher.state}>
|
||||
Grant API access
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={giveApiAccessSchema}
|
||||
title="Give API access"
|
||||
submitButtonText="Grant API access"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateFriendCode() {
|
||||
const fetcher = useFetcher();
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Update friend code</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
<div className="flex-same-size">
|
||||
<label htmlFor={id}>Friend code</label>
|
||||
<Input
|
||||
leftAddon="SW-"
|
||||
id={id}
|
||||
name="friendCode"
|
||||
pattern={FRIEND_CODE_REGEXP_PATTERN}
|
||||
placeholder="1234-5678-9012"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
_action="UPDATE_FRIEND_CODE"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Submit
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm schema={updateFriendCodeSchema} title="Update friend code">
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="friendCode" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ForcePatron() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2>Force patron</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
|
||||
<div className="flex-same-size">
|
||||
<label>Tier</label>
|
||||
<select name="patronTier">
|
||||
<option value="1">Support</option>
|
||||
<option value="2">Supporter</option>
|
||||
<option value="3">Supporter+</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-same-size">
|
||||
<label>Patron till</label>
|
||||
<input name="patronExpiresAt" type="date" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton
|
||||
type="submit"
|
||||
_action="FORCE_PATRON"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={forcePatronSchema}
|
||||
title="Force patron"
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="patronTier" />
|
||||
<FormField name="patronExpiresAt" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function BanUser() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2 className="text-warning">Ban user</h2>
|
||||
<div className="stack horizontal md">
|
||||
<div className="flex-same-size">
|
||||
<UserSearch label="User" name="user" />
|
||||
</div>
|
||||
|
||||
<div className="flex-same-size">
|
||||
<label>Banned till</label>
|
||||
<input name="duration" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div className="flex-same-size">
|
||||
<label>Reason</label>
|
||||
<input name="reason" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="BAN_USER" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={banUserSchema}
|
||||
title={<span className="text-warning">Ban user</span>}
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="user" />
|
||||
<FormField name="expiresAt" />
|
||||
<FormField name="reason" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function UnbanUser() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<h2 className="text-warning">Unban user</h2>
|
||||
<UserSearch label="User" name="user" />
|
||||
<div className="stack horizontal md">
|
||||
<SubmitButton type="submit" _action="UNBAN_USER" state={fetcher.state}>
|
||||
Save
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={unbanUserSchema}
|
||||
title={<span className="text-warning">Unban user</span>}
|
||||
submitButtonText="Save"
|
||||
>
|
||||
{({ FormField }) => <FormField name="user" />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshPlusTiers() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post">
|
||||
<h2>Refresh Plus Tiers</h2>
|
||||
<SubmitButton type="submit" _action="REFRESH" state={fetcher.state}>
|
||||
Refresh
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
<SendouForm
|
||||
schema={refreshPlusTiersSchema}
|
||||
title="Refresh Plus Tiers"
|
||||
submitButtonText="Refresh"
|
||||
>
|
||||
{null}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,57 @@
|
||||
import type { FileUpload } from "@remix-run/form-data-parser";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import * as ArtRepository from "~/features/art/ArtRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
|
||||
import { ALLOWED_IMAGE_EXTENSIONS } from "~/features/img-upload/upload-constants";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseFormData,
|
||||
parseRequestPayload,
|
||||
safeParseMultipartFormData,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { userArtPage } from "~/utils/urls";
|
||||
import { NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
|
||||
import { editArtSchema, newArtSchema } from "../art-schemas.server";
|
||||
import { ART_FORM_MAX_BODY_BYTES } from "../art-image";
|
||||
import { uploadArtImage } from "../art-image.server";
|
||||
import { artFormSchema } from "../art-schemas";
|
||||
|
||||
export const action: ActionFunction = async ({ request, url }) => {
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
requireRole("ARTIST");
|
||||
|
||||
const searchParams = url.searchParams;
|
||||
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: artFormSchema,
|
||||
maxBodyBytes: ART_FORM_MAX_BODY_BYTES,
|
||||
});
|
||||
|
||||
// updating logic
|
||||
if (artIdRaw) {
|
||||
const artId = Number(artIdRaw);
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
const linkedUsers = R.unique(
|
||||
data.linkedUsers.filter((userId) => typeof userId === "number"),
|
||||
);
|
||||
|
||||
if (data.artId) {
|
||||
const userArts = await ArtRepository.findArtsByUserId(user.id, {
|
||||
includeTagged: false,
|
||||
});
|
||||
const existingArt = userArts.find((art) => art.id === artId);
|
||||
const existingArt = userArts.find((art) => art.id === data.artId);
|
||||
errorToastIfFalsy(existingArt, "Art author is someone else");
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: editArtSchema,
|
||||
});
|
||||
|
||||
const editedArtId = await ArtRepository.update(artId, {
|
||||
const editedArtId = await ArtRepository.update(data.artId, {
|
||||
description: data.description,
|
||||
isShowcase: data.isShowcase,
|
||||
linkedUsers: data.linkedUsers,
|
||||
isShowcase: toDBBoolean(data.isShowcase),
|
||||
linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
const existingLinkedUserIds =
|
||||
existingArt.linkedUsers?.map((u) => u.id) ?? [];
|
||||
const newLinkedUsers = data.linkedUsers.filter(
|
||||
(userId) => !existingLinkedUserIds.includes(userId),
|
||||
);
|
||||
|
||||
notify({
|
||||
userIds: newLinkedUsers,
|
||||
userIds: R.difference(linkedUsers, existingLinkedUserIds),
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
@@ -67,61 +62,18 @@ export const action: ActionFunction = async ({ request, url }) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const preDecidedFilename = `art-${nanoid()}-${Date.now()}`;
|
||||
|
||||
const uploadHandler = async (fileUpload: FileUpload) => {
|
||||
if (
|
||||
fileUpload.fieldName === "img" ||
|
||||
fileUpload.fieldName === "smallImg"
|
||||
) {
|
||||
const ending = fileUpload.name.split(".").pop()?.toLowerCase();
|
||||
invariant(
|
||||
ending && ending !== fileUpload.name,
|
||||
`File missing extension: "${fileUpload.name}"`,
|
||||
);
|
||||
invariant(
|
||||
ALLOWED_IMAGE_EXTENSIONS.includes(ending),
|
||||
`Invalid file extension: "${ending}"`,
|
||||
);
|
||||
const newFilename = `${preDecidedFilename}${fileUpload.fieldName === "smallImg" ? "-small" : ""}.${ending}`;
|
||||
|
||||
const uploadedFileLocation = await uploadStreamToS3(
|
||||
fileUpload.stream(),
|
||||
newFilename,
|
||||
);
|
||||
return uploadedFileLocation;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formData = await safeParseMultipartFormData(
|
||||
request,
|
||||
// 5MB
|
||||
{ maxFileSize: 5 * 1024 * 1024 },
|
||||
uploadHandler,
|
||||
);
|
||||
const imgSrc = formData.get("img") as string | null;
|
||||
invariant(imgSrc);
|
||||
|
||||
const urlParts = imgSrc.split("/");
|
||||
const fileName = urlParts[urlParts.length - 1];
|
||||
invariant(fileName);
|
||||
|
||||
const data = await parseFormData({
|
||||
formData,
|
||||
schema: newArtSchema,
|
||||
});
|
||||
errorToastIfFalsy(data.img?.type === "NEW", "Art image is missing");
|
||||
|
||||
const addedArt = await ArtRepository.insert({
|
||||
description: data.description,
|
||||
url: fileName,
|
||||
url: await uploadArtImage(data.img),
|
||||
validatedAt: user.patronTier ? dateToDatabaseTimestamp(new Date()) : null,
|
||||
linkedUsers: data.linkedUsers,
|
||||
linkedUsers,
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
notify({
|
||||
userIds: data.linkedUsers,
|
||||
userIds: linkedUsers,
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
|
||||
43
app/features/art/art-image.server.ts
Normal file
43
app/features/art/art-image.server.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { basename } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { dataUrlToImageBuffer } from "~/features/img-upload/image-bytes.server";
|
||||
import { uploadStreamToS3 } from "~/features/img-upload/s3.server";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { previewUrl } from "./art-utils";
|
||||
|
||||
const ALLOWED_ART_IMAGE_EXTENSIONS = ["png", "jpeg", "webp"] as const;
|
||||
|
||||
/**
|
||||
* Uploads both assets a newly submitted art needs — the full image and its thumbnail, following
|
||||
* the `<name>-small.<ext>` convention {@link previewUrl} resolves — and returns the full image's
|
||||
* file name to store on the art's image row.
|
||||
*/
|
||||
export async function uploadArtImage({
|
||||
dataUrl,
|
||||
thumbnailDataUrl,
|
||||
}: {
|
||||
dataUrl: string;
|
||||
thumbnailDataUrl: string;
|
||||
}): Promise<string> {
|
||||
const image = dataUrlToImageBuffer(dataUrl, ALLOWED_ART_IMAGE_EXTENSIONS);
|
||||
const thumbnail = dataUrlToImageBuffer(
|
||||
thumbnailDataUrl,
|
||||
ALLOWED_ART_IMAGE_EXTENSIONS,
|
||||
);
|
||||
|
||||
invariant(
|
||||
image.extension === thumbnail.extension,
|
||||
"Art image and its thumbnail are of a different format",
|
||||
);
|
||||
|
||||
const fileName = `art-${Date.now()}-${shortNanoid()}.${image.extension}`;
|
||||
|
||||
const [uploadedLocation] = await Promise.all([
|
||||
uploadStreamToS3(Readable.from(image.buffer), fileName),
|
||||
uploadStreamToS3(Readable.from(thumbnail.buffer), previewUrl(fileName)),
|
||||
]);
|
||||
invariant(uploadedLocation, "Art image upload failed");
|
||||
|
||||
return basename(uploadedLocation);
|
||||
}
|
||||
81
app/features/art/art-image.ts
Normal file
81
app/features/art/art-image.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the
|
||||
* uploaded image's own format instead of normalizing everything to webp.
|
||||
*/
|
||||
const ART_IMAGE_DATA_URL_PREFIX_REGEX = /^data:image\/(png|jpeg|webp);base64,/;
|
||||
|
||||
/**
|
||||
* Largest full-size art image accepted, decoded. Art is submitted at its original resolution and
|
||||
* png keeps its detail losslessly, so this needs the same headroom the multipart upload flow used
|
||||
* to allow.
|
||||
*/
|
||||
const ART_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Largest art thumbnail accepted, decoded. The client caps the thumbnail's width to
|
||||
* `ART.THUMBNAIL_WIDTH`, which lands well below this.
|
||||
*/
|
||||
const ART_THUMBNAIL_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Ceiling for the whole art submit body. Fits both data URLs at their maximum plus the rest of the
|
||||
* form's fields.
|
||||
*/
|
||||
export const ART_FORM_MAX_BODY_BYTES =
|
||||
maxDataUrlLength(ART_IMAGE_MAX_BYTES + ART_THUMBNAIL_MAX_BYTES) + 100_000;
|
||||
|
||||
/** Error shown when a picked image doesn't fit within the limits above. */
|
||||
export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge";
|
||||
|
||||
const artImageDataUrl = (maxBytes: number) =>
|
||||
z
|
||||
.string()
|
||||
.max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR)
|
||||
.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX);
|
||||
|
||||
/**
|
||||
* JSON-serializable value of the art image form field. Art can't use the generic `image()` field:
|
||||
* it preserves aspect ratio, keeps the original format and derives a separate thumbnail, which is
|
||||
* why a `NEW` value carries two data URLs. An `EXISTING` value marks art whose image was already
|
||||
* uploaded (only the preview url rides along, never bytes) — art images can't be swapped after
|
||||
* upload.
|
||||
*/
|
||||
export const artImageValue = z
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal("EXISTING"),
|
||||
url: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("NEW"),
|
||||
dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES),
|
||||
thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES),
|
||||
}),
|
||||
])
|
||||
.nullable();
|
||||
|
||||
export type ArtImageValue = z.infer<typeof artImageValue>;
|
||||
|
||||
/**
|
||||
* Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject
|
||||
* an oversized pick right away instead of only when the filled-out form is submitted.
|
||||
*/
|
||||
export function isArtImageTooLarge({
|
||||
dataUrl,
|
||||
thumbnailDataUrl,
|
||||
}: {
|
||||
dataUrl: string;
|
||||
thumbnailDataUrl: string;
|
||||
}) {
|
||||
return (
|
||||
dataUrl.length > maxDataUrlLength(ART_IMAGE_MAX_BYTES) ||
|
||||
thumbnailDataUrl.length > maxDataUrlLength(ART_THUMBNAIL_MAX_BYTES)
|
||||
);
|
||||
}
|
||||
|
||||
/** Length a base64 data URL encoding `bytes` decoded bytes can reach, `data:` prefix included. */
|
||||
function maxDataUrlLength(bytes: number) {
|
||||
return Math.ceil(bytes / 3) * 4 + 32;
|
||||
}
|
||||
@@ -1,47 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
_action,
|
||||
checkboxValueToDbBoolean,
|
||||
dbBoolean,
|
||||
falsyToNull,
|
||||
id,
|
||||
processMany,
|
||||
removeDuplicates,
|
||||
safeJSONParse,
|
||||
} from "~/utils/zod";
|
||||
import { ART } from "./art-constants";
|
||||
|
||||
const description = z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(ART.DESCRIPTION_MAX_LENGTH).nullable(),
|
||||
);
|
||||
const linkedUsers = z.preprocess(
|
||||
processMany(safeJSONParse, removeDuplicates),
|
||||
z.array(id).max(ART.LINKED_USERS_MAX_LENGTH),
|
||||
);
|
||||
const tags = z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
|
||||
id: id.optional(),
|
||||
}),
|
||||
)
|
||||
.max(ART.TAG_MAX_LENGTH),
|
||||
);
|
||||
export const newArtSchema = z.object({
|
||||
description,
|
||||
linkedUsers,
|
||||
tags,
|
||||
});
|
||||
|
||||
export const editArtSchema = z.object({
|
||||
description,
|
||||
linkedUsers,
|
||||
tags,
|
||||
isShowcase: z.preprocess(checkboxValueToDbBoolean, dbBoolean),
|
||||
});
|
||||
import { _action, id } from "~/utils/zod";
|
||||
|
||||
const deleteArtSchema = z.object({
|
||||
_action: _action("DELETE_ART"),
|
||||
|
||||
52
app/features/art/art-schemas.ts
Normal file
52
app/features/art/art-schemas.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
array,
|
||||
customField,
|
||||
idConstantOptional,
|
||||
textAreaOptional,
|
||||
toggle,
|
||||
userSearchOptional,
|
||||
} from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { ART } from "./art-constants";
|
||||
import { artImageValue } from "./art-image";
|
||||
|
||||
const artTags = z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
|
||||
id: id.optional(),
|
||||
}),
|
||||
)
|
||||
.max(ART.TAGS_MAX_LENGTH);
|
||||
|
||||
export const artFormSchema = z
|
||||
.object({
|
||||
artId: idConstantOptional(),
|
||||
img: customField({ initialValue: null }, artImageValue),
|
||||
description: textAreaOptional({
|
||||
label: "labels.description",
|
||||
maxLength: ART.DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
tags: customField({ initialValue: [] }, artTags),
|
||||
linkedUsers: array({
|
||||
label: "labels.linkedUsers",
|
||||
bottomText: "bottomTexts.linkedUsers",
|
||||
max: ART.LINKED_USERS_MAX_LENGTH,
|
||||
field: userSearchOptional({ label: "labels.user" }),
|
||||
}),
|
||||
isShowcase: toggle({
|
||||
label: "labels.showcase",
|
||||
bottomText: "bottomTexts.showcase",
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// existing art keeps its image, new art must bring one
|
||||
if (!data.artId && data.img?.type !== "NEW") {
|
||||
ctx.addIssue({
|
||||
path: ["img"],
|
||||
code: "custom",
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -89,7 +89,7 @@ export function ArtGrid({
|
||||
}
|
||||
|
||||
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
const [imageSettled, setImageSettled] = React.useState(false);
|
||||
const { formatter } = useDateTimeFormat({
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
@@ -107,11 +107,12 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
||||
src={art.url}
|
||||
loading="lazy"
|
||||
className={styles.dialogImg}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onLoad={() => setImageSettled(true)}
|
||||
onError={() => setImageSettled(true)}
|
||||
/>
|
||||
{art.tags || art.linkedUsers ? (
|
||||
<div
|
||||
className={clsx(styles.tagsContainer, { invisible: !imageLoaded })}
|
||||
className={clsx(styles.tagsContainer, { invisible: !imageSettled })}
|
||||
>
|
||||
{art.linkedUsers?.map((user) => (
|
||||
<Link
|
||||
@@ -136,7 +137,7 @@ function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
||||
{art.description ? (
|
||||
<div
|
||||
className={clsx(styles.dialogDescription, {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
{art.description}
|
||||
@@ -167,7 +168,7 @@ function ImagePreview({
|
||||
canEdit?: boolean;
|
||||
showUploadDate?: boolean;
|
||||
}) {
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
const [imageSettled, setImageSettled] = React.useState(false);
|
||||
const { t } = useTranslation(["common", "art"]);
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
|
||||
@@ -178,7 +179,8 @@ function ImagePreview({
|
||||
src={previewUrl(art.url)}
|
||||
loading="lazy"
|
||||
onClick={onClick}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onLoad={() => setImageSettled(true)}
|
||||
onError={() => setImageSettled(true)}
|
||||
className={enablePreview ? styles.thumbnail : undefined}
|
||||
data-testid="art-image"
|
||||
/>
|
||||
@@ -190,7 +192,7 @@ function ImagePreview({
|
||||
{img}
|
||||
<div
|
||||
className={clsx("stack horizontal justify-between mt-2", {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
<LinkButton
|
||||
@@ -235,7 +237,7 @@ function ImagePreview({
|
||||
<Link
|
||||
to={userArtPage(art.author, "MADE-BY")}
|
||||
className={clsx("stack sm horizontal text-xs items-center mt-1", {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
<Avatar user={art.author} size="xxs" />
|
||||
@@ -244,7 +246,7 @@ function ImagePreview({
|
||||
{uploadDateText ? (
|
||||
<div
|
||||
className={clsx("text-xs text-lighter", {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
{uploadDateText}
|
||||
@@ -279,7 +281,7 @@ function ImagePreview({
|
||||
<div className="stack horizontal justify-between">
|
||||
<div
|
||||
className={clsx("stack sm horizontal text-xs items-center mt-1", {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
<Avatar user={art.author} size="xxs" />
|
||||
@@ -288,7 +290,7 @@ function ImagePreview({
|
||||
{uploadDateText ? (
|
||||
<div
|
||||
className={clsx("text-xxs mt-1 text-lighter", {
|
||||
invisible: !imageLoaded,
|
||||
invisible: !imageSettled,
|
||||
})}
|
||||
>
|
||||
{uploadDateText}
|
||||
|
||||
3
app/features/art/components/ArtImageFormField.module.css
Normal file
3
app/features/art/components/ArtImageFormField.module.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.preview {
|
||||
max-width: 100%;
|
||||
}
|
||||
115
app/features/art/components/ArtImageFormField.tsx
Normal file
115
app/features/art/components/ArtImageFormField.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import clsx from "clsx";
|
||||
import Compressor from "compressorjs";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { ART } from "../art-constants";
|
||||
import {
|
||||
ART_IMAGE_TOO_LARGE_ERROR,
|
||||
type ArtImageValue,
|
||||
isArtImageTooLarge,
|
||||
} from "../art-image";
|
||||
import { previewUrl } from "../art-utils";
|
||||
import styles from "./ArtImageFormField.module.css";
|
||||
|
||||
type ArtImageFormFieldProps = Omit<
|
||||
CustomFieldRenderProps<ArtImageValue>,
|
||||
"name"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Image picker for art. Produces both derived assets the art pipeline needs — the full image with
|
||||
* its aspect ratio and format preserved, and a thumbnail — as base64 data URLs so they can ride
|
||||
* along in `SendouForm`'s single JSON submit. Art of already uploaded art can't be swapped, so an
|
||||
* `EXISTING` value renders as a plain preview.
|
||||
*/
|
||||
export function ArtImageFormField({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: ArtImageFormFieldProps) {
|
||||
const id = React.useId();
|
||||
const [tooLargeError, setTooLargeError] = React.useState<string>();
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
if (value?.type === "EXISTING") {
|
||||
return <img src={previewUrl(value.url)} alt="" />;
|
||||
}
|
||||
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
setTooLargeError(undefined);
|
||||
|
||||
const uploadedFile = event.target.files?.[0];
|
||||
if (!uploadedFile) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [dataUrl, thumbnailDataUrl] = await Promise.all([
|
||||
compressToDataUrl(uploadedFile, {}),
|
||||
compressToDataUrl(uploadedFile, { maxWidth: ART.THUMBNAIL_WIDTH }),
|
||||
]);
|
||||
|
||||
if (isArtImageTooLarge({ dataUrl, thumbnailDataUrl })) {
|
||||
setTooLargeError(ART_IMAGE_TOO_LARGE_ERROR);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange({ type: "NEW", dataUrl, thumbnailDataUrl });
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
onChange(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
id={id}
|
||||
name="img"
|
||||
label={t("common:upload.imageToUpload")}
|
||||
error={tooLargeError ?? error}
|
||||
required
|
||||
>
|
||||
<div className="stack sm items-start">
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
{value ? (
|
||||
<img
|
||||
src={value.dataUrl}
|
||||
alt=""
|
||||
className={clsx(styles.preview, "rounded")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
function compressToDataUrl(
|
||||
file: File,
|
||||
options: Compressor.Options,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
new Compressor(file, {
|
||||
...options,
|
||||
success(result) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () =>
|
||||
reject(new Error("Failed to read compressed image"));
|
||||
reader.readAsDataURL(result);
|
||||
},
|
||||
error: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
11
app/features/art/components/ArtTagsFormField.module.css
Normal file
11
app/features/art/components/ArtTagsFormField.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
/* the minimal button variant keeps the full field height and its own font size, which breaks
|
||||
the baseline when it sits inline in a line of helper text */
|
||||
.switcherButton {
|
||||
height: auto;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* the new tag input is stretched to the field width, so the button must keep its own */
|
||||
.addButton {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
152
app/features/art/components/ArtTagsFormField.tsx
Normal file
152
app/features/art/components/ArtTagsFormField.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
|
||||
import { ART } from "../art-constants";
|
||||
import styles from "./ArtTagsFormField.module.css";
|
||||
import { TagSelect } from "./TagSelect";
|
||||
|
||||
export type ArtTag = { name?: string; id?: number };
|
||||
|
||||
type ArtTagsFormFieldProps = Omit<CustomFieldRenderProps<ArtTag[]>, "name"> & {
|
||||
/** All tags that exist in the database, selectable without creating a new one. */
|
||||
existingTags: Array<{ id: number; name: string }>;
|
||||
};
|
||||
|
||||
// note: not handling edge case where a tag was added by another user while this
|
||||
// user was adding a new art with the same tag -> will crash
|
||||
export function ArtTagsFormField({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
existingTags,
|
||||
}: ArtTagsFormFieldProps) {
|
||||
const id = React.useId();
|
||||
const { t } = useTranslation(["art", "common"]);
|
||||
const [creationMode, setCreationMode] = React.useState(false);
|
||||
const [newTagValue, setNewTagValue] = React.useState("");
|
||||
|
||||
const handleAddNewTag = () => {
|
||||
const normalizedNewTagValue = newTagValue
|
||||
.trim()
|
||||
// replace many whitespaces with one
|
||||
.replace(/\s\s+/g, " ")
|
||||
.toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedNewTagValue.length === 0 ||
|
||||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyCreatedTag = existingTags.find(
|
||||
(tag) => tag.name === normalizedNewTagValue,
|
||||
);
|
||||
|
||||
if (alreadyCreatedTag) {
|
||||
onChange([...value, alreadyCreatedTag]);
|
||||
} else if (value.every((tag) => tag.name !== normalizedNewTagValue)) {
|
||||
onChange([...value, { name: normalizedNewTagValue }]);
|
||||
}
|
||||
|
||||
setNewTagValue("");
|
||||
setCreationMode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
id={id}
|
||||
name="tags"
|
||||
label={t("art:forms.tags.title")}
|
||||
error={error}
|
||||
>
|
||||
<div className="stack xs">
|
||||
{value.length >= ART.TAGS_MAX_LENGTH ? (
|
||||
<div className="text-sm text-warning">
|
||||
{t("art:forms.tags.maxReached")}
|
||||
</div>
|
||||
) : creationMode ? (
|
||||
<>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input
|
||||
id={id}
|
||||
placeholder={t("art:forms.tags.addNew.placeholder")}
|
||||
value={newTagValue}
|
||||
onChange={(e) => setNewTagValue(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === "Enter") {
|
||||
handleAddNewTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
className={styles.addButton}
|
||||
onPress={handleAddNewTag}
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
<div className="text-xs text-lighter">
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.switcherButton}
|
||||
onPress={() => setCreationMode(false)}
|
||||
>
|
||||
{t("art:forms.tags.selectFromExisting")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TagSelect
|
||||
// empty combobox on select
|
||||
key={value.length}
|
||||
tags={existingTags}
|
||||
disabledKeys={value
|
||||
.map((tag) => tag.id)
|
||||
.filter((id) => id !== undefined)}
|
||||
onSelectionChange={(tagName) =>
|
||||
onChange([
|
||||
...value,
|
||||
existingTags.find((tag) => tag.name === tagName)!,
|
||||
])
|
||||
}
|
||||
/>
|
||||
<div className="stack horizontal xs items-center text-xs text-lighter">
|
||||
{t("art:forms.tags.cantFindExisting")}
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
className={styles.switcherButton}
|
||||
onPress={() => setCreationMode(true)}
|
||||
>
|
||||
{t("art:forms.tags.addNew")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{value.length > 0 ? (
|
||||
<div className="text-sm stack sm flex-wrap horizontal">
|
||||
{value.map((tag) => (
|
||||
<div key={tag.name} className="stack horizontal xs items-center">
|
||||
{tag.name}
|
||||
<SendouButton
|
||||
icon={<X />}
|
||||
size="miniscule"
|
||||
variant="minimal-destructive"
|
||||
onPress={() =>
|
||||
onChange(value.filter((it) => it.name !== tag.name))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,20 @@
|
||||
import Compressor from "compressorjs";
|
||||
import { X } from "lucide-react";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Form, useFetcher, useLoaderData } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { artPage, navIconUrl } from "~/utils/urls";
|
||||
import { metaTitle } from "../../../utils/remix";
|
||||
import { action } from "../actions/art.new.server";
|
||||
import { ART } from "../art-constants";
|
||||
import { previewUrl } from "../art-utils";
|
||||
import { TagSelect } from "../components/TagSelect";
|
||||
import type { ArtImageValue } from "../art-image";
|
||||
import { artFormSchema } from "../art-schemas";
|
||||
import { ArtImageFormField } from "../components/ArtImageFormField";
|
||||
import { type ArtTag, ArtTagsFormField } from "../components/ArtTagsFormField";
|
||||
import { loader } from "../loaders/art.new.server";
|
||||
|
||||
export { action, loader };
|
||||
@@ -43,31 +36,9 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export default function NewArtPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [img, setImg] = React.useState<File | null>(null);
|
||||
const [smallImg, setSmallImg] = React.useState<File | null>(null);
|
||||
const { t } = useTranslation(["common", "art"]);
|
||||
const ref = React.useRef<HTMLFormElement>(null);
|
||||
const fetcher = useFetcher();
|
||||
const { t } = useTranslation(["art"]);
|
||||
const isArtist = useHasRole("ARTIST");
|
||||
|
||||
const handleSubmit = () => {
|
||||
const formData = new FormData(ref.current!);
|
||||
|
||||
if (img) formData.append("img", img, img.name);
|
||||
if (smallImg) formData.append("smallImg", smallImg, smallImg.name);
|
||||
|
||||
fetcher.submit(formData, {
|
||||
encType: "multipart/form-data",
|
||||
method: "post",
|
||||
});
|
||||
};
|
||||
|
||||
const submitButtonDisabled = () => {
|
||||
if (fetcher.state !== "idle") return true;
|
||||
|
||||
return (!img || !smallImg) && !data.art;
|
||||
};
|
||||
|
||||
if (!isArtist) {
|
||||
return (
|
||||
<Main className="stack items-center">
|
||||
@@ -76,321 +47,51 @@ export default function NewArtPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<Form ref={ref} className="stack md">
|
||||
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
|
||||
<ImageUpload img={img} setImg={setImg} setSmallImg={setSmallImg} />
|
||||
<Description />
|
||||
<Tags />
|
||||
<LinkedUsers />
|
||||
{data.art ? <ShowcaseToggle /> : null}
|
||||
<div>
|
||||
<SendouButton
|
||||
onPress={handleSubmit}
|
||||
isDisabled={submitButtonDisabled()}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</Form>
|
||||
<SendouForm
|
||||
schema={artFormSchema}
|
||||
defaultValues={{
|
||||
artId: data.art?.id,
|
||||
img: data.art ? { type: "EXISTING", url: data.art.url } : null,
|
||||
description: data.art?.description ?? "",
|
||||
tags: data.art?.tags ?? [],
|
||||
linkedUsers: data.art?.linkedUsers?.map((user) => user.id) ?? [],
|
||||
isShowcase: isCurrentlyShowcase,
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormMessage type="info">{t("art:forms.caveats")}</FormMessage>
|
||||
<FormField name="img">
|
||||
{({ value, onChange, error }: CustomFieldRenderProps) => (
|
||||
<ArtImageFormField
|
||||
value={value as ArtImageValue}
|
||||
onChange={onChange as (value: ArtImageValue) => void}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="description" />
|
||||
<FormField name="tags">
|
||||
{({ value, onChange, error }: CustomFieldRenderProps) => (
|
||||
<ArtTagsFormField
|
||||
value={value as ArtTag[]}
|
||||
onChange={onChange as (value: ArtTag[]) => void}
|
||||
error={error}
|
||||
existingTags={data.tags}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="linkedUsers" />
|
||||
{data.art ? (
|
||||
<FormField name="isShowcase" disabled={isCurrentlyShowcase} />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageUpload({
|
||||
img,
|
||||
setImg,
|
||||
setSmallImg,
|
||||
}: {
|
||||
img: File | null;
|
||||
setImg: (file: File | null) => void;
|
||||
setSmallImg: (file: File | null) => void;
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common"]);
|
||||
const id = React.useId();
|
||||
|
||||
if (data.art) {
|
||||
return <img src={previewUrl(data.art.url)} alt="" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{t("common:upload.imageToUpload")}</label>
|
||||
<input
|
||||
id={id}
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/webp"
|
||||
onChange={(e) => {
|
||||
const uploadedFile = e.target.files?.[0];
|
||||
if (!uploadedFile) {
|
||||
setImg(null);
|
||||
return;
|
||||
}
|
||||
|
||||
new Compressor(uploadedFile, {
|
||||
success(result) {
|
||||
invariant(result instanceof Blob);
|
||||
const file = new File([result], uploadedFile.name);
|
||||
|
||||
setImg(file);
|
||||
},
|
||||
error(err) {
|
||||
logger.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
new Compressor(uploadedFile, {
|
||||
maxWidth: ART.THUMBNAIL_WIDTH,
|
||||
success(result) {
|
||||
invariant(result instanceof Blob);
|
||||
const file = new File([result], uploadedFile.name);
|
||||
|
||||
setSmallImg(file);
|
||||
},
|
||||
error(err) {
|
||||
logger.error(err.message);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{img && <img src={URL.createObjectURL(img)} alt="" className="mt-4" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Description() {
|
||||
const { t } = useTranslation(["art"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [value, setValue] = React.useState(data.art?.description ?? "");
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
valueLimits={{ current: value.length, max: ART.DESCRIPTION_MAX_LENGTH }}
|
||||
>
|
||||
{t("art:forms.description.title")}
|
||||
</Label>
|
||||
<textarea
|
||||
id={id}
|
||||
name="description"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={ART.DESCRIPTION_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// note: not handling edge case where a tag was added by another user while this
|
||||
// user was adding a new art with the same tag -> will crash
|
||||
function Tags() {
|
||||
const { t } = useTranslation(["art", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [creationMode, setCreationMode] = React.useState(false);
|
||||
const [tags, setTags] = React.useState<{ name?: string; id?: number }[]>(
|
||||
data.art?.tags ?? [],
|
||||
);
|
||||
const [newTagValue, setNewTagValue] = React.useState("");
|
||||
|
||||
const handleAddNewTag = () => {
|
||||
const normalizedNewTagValue = newTagValue
|
||||
.trim()
|
||||
// replace many whitespaces with one
|
||||
.replace(/\s\s+/g, " ")
|
||||
.toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedNewTagValue.length === 0 ||
|
||||
normalizedNewTagValue.length > ART.TAG_MAX_LENGTH
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyCreatedTag = data.tags.find(
|
||||
(t) => t.name === normalizedNewTagValue,
|
||||
);
|
||||
|
||||
if (alreadyCreatedTag) {
|
||||
setTags((tags) => [...tags, alreadyCreatedTag]);
|
||||
} else if (tags.every((tag) => tag.name !== normalizedNewTagValue)) {
|
||||
setTags((tags) => [...tags, { name: normalizedNewTagValue }]);
|
||||
}
|
||||
|
||||
setNewTagValue("");
|
||||
setCreationMode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack xs items-start">
|
||||
<Label htmlFor="tags" className="mb-0">
|
||||
{t("art:forms.tags.title")}
|
||||
</Label>
|
||||
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
|
||||
{creationMode ? (
|
||||
<div className="art__creation-mode-switcher-container">
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
onPress={() => setCreationMode(false)}
|
||||
>
|
||||
{t("art:forms.tags.selectFromExisting")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack horizontal sm text-xs text-lighter art__creation-mode-switcher-container">
|
||||
{t("art:forms.tags.cantFindExisting")}{" "}
|
||||
<SendouButton variant="minimal" onPress={() => setCreationMode(true)}>
|
||||
{t("art:forms.tags.addNew")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
)}
|
||||
{tags.length >= ART.TAGS_MAX_LENGTH ? (
|
||||
<div className="text-sm text-warning">
|
||||
{t("art:forms.tags.maxReached")}
|
||||
</div>
|
||||
) : creationMode ? (
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input
|
||||
placeholder={t("art:forms.tags.addNew.placeholder")}
|
||||
name="tag"
|
||||
value={newTagValue}
|
||||
onChange={(e) => setNewTagValue(e.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.code === "Enter") {
|
||||
handleAddNewTag();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onPress={handleAddNewTag}
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
) : (
|
||||
<TagSelect
|
||||
// empty combobox on select
|
||||
key={tags.length}
|
||||
tags={data.tags}
|
||||
disabledKeys={tags.map((t) => t.id).filter((id) => id !== undefined)}
|
||||
onSelectionChange={(tagName) =>
|
||||
setTags([...tags, data.tags.find((t) => t.name === tagName)!])
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm stack sm flex-wrap horizontal">
|
||||
{tags.map((t) => {
|
||||
return (
|
||||
<div key={t.name} className="stack horizontal">
|
||||
{t.name}{" "}
|
||||
<SendouButton
|
||||
icon={<X />}
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
className="art__delete-tag-button"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag.name !== t.name));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedUsers() {
|
||||
const { t } = useTranslation(["art"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [users, setUsers] = React.useState<
|
||||
{ inputId: string; userId?: number }[]
|
||||
>(
|
||||
data.art?.linkedUsers && data.art.linkedUsers.length > 0
|
||||
? data.art.linkedUsers.map((user) => ({
|
||||
userId: user.id,
|
||||
inputId: nanoid(),
|
||||
}))
|
||||
: [{ inputId: nanoid() }],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="user">{t("art:forms.linkedUsers.title")}</label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="linkedUsers"
|
||||
value={JSON.stringify(
|
||||
users.filter((u) => u.userId).map((u) => u.userId),
|
||||
)}
|
||||
/>
|
||||
{users.map(({ inputId, userId }, i) => {
|
||||
return (
|
||||
<div key={inputId} className="stack horizontal sm mb-2 items-center">
|
||||
<UserSearch
|
||||
name="user"
|
||||
onChange={(newUser) => {
|
||||
const newUsers = structuredClone(users);
|
||||
newUsers[i] = { ...newUsers[i], userId: newUser?.id };
|
||||
|
||||
setUsers(newUsers);
|
||||
}}
|
||||
initialUserId={userId}
|
||||
/>
|
||||
{users.length > 1 || users[0].userId ? (
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={() => {
|
||||
if (users.length === 1) {
|
||||
setUsers([{ inputId: nanoid() }]);
|
||||
} else {
|
||||
setUsers(users.filter((u) => u.inputId !== inputId));
|
||||
}
|
||||
}}
|
||||
icon={<X />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={() => setUsers([...users, { inputId: nanoid() }])}
|
||||
isDisabled={users.length >= ART.LINKED_USERS_MAX_LENGTH}
|
||||
className="my-3"
|
||||
variant="outlined"
|
||||
>
|
||||
{t("art:forms.linkedUsers.anotherOne")}
|
||||
</SendouButton>
|
||||
<FormMessage type="info">{t("art:forms.linkedUsers.info")}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowcaseToggle() {
|
||||
const { t } = useTranslation(["art"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
|
||||
const [checked, setChecked] = React.useState(isCurrentlyShowcase);
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{t("art:forms.showcase.title")}</label>
|
||||
<SendouSwitch
|
||||
isSelected={checked}
|
||||
onChange={setChecked}
|
||||
name="isShowcase"
|
||||
id={id}
|
||||
isDisabled={isCurrentlyShowcase}
|
||||
/>
|
||||
<FormMessage type="info">{t("art:forms.showcase.info")}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { textFieldRequired } from "~/form/fields";
|
||||
import { textField } from "~/form/fields";
|
||||
import { _action, id, inviteCode } from "~/utils/zod";
|
||||
import { ASSOCIATION } from "./associations-constants";
|
||||
|
||||
export const createNewAssociationSchema = z.object({
|
||||
name: textFieldRequired({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
maxLength: 100,
|
||||
}),
|
||||
|
||||
@@ -12,6 +12,7 @@ export function BadgesSelector({
|
||||
children,
|
||||
maxCount,
|
||||
showSelect = true,
|
||||
disabled,
|
||||
}: {
|
||||
options: BadgeDisplayProps["badges"];
|
||||
selectedBadges: number[];
|
||||
@@ -20,6 +21,7 @@ export function BadgesSelector({
|
||||
children?: React.ReactNode;
|
||||
maxCount?: number;
|
||||
showSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
@@ -35,7 +37,7 @@ export function BadgesSelector({
|
||||
|
||||
return aIdx - bIdx;
|
||||
})}
|
||||
onChange={onChange}
|
||||
onChange={disabled ? undefined : onChange}
|
||||
key={selectedBadges.join(",")}
|
||||
>
|
||||
{children}
|
||||
@@ -56,7 +58,9 @@ export function BadgesSelector({
|
||||
onChange={(e) =>
|
||||
onChange([...selectedBadges, Number(e.target.value)])
|
||||
}
|
||||
disabled={Boolean(maxCount && selectedBadges.length >= maxCount)}
|
||||
disabled={
|
||||
disabled || Boolean(maxCount && selectedBadges.length >= maxCount)
|
||||
}
|
||||
data-testid="badges-selector"
|
||||
>
|
||||
<option>{t("common:badges.selector.select")}</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DamageType } from "./analyzer-types";
|
||||
import type { DamageType, TenacityPlayerDeficit } from "./analyzer-types";
|
||||
|
||||
export const MAX_LDE_INTENSITY = 21;
|
||||
|
||||
@@ -109,3 +109,16 @@ export const MAX_AP = 57;
|
||||
|
||||
export const MAIN_SLOT_AP = 10;
|
||||
export const SUB_SLOT_AP = 3;
|
||||
|
||||
/** How many active players the opponent's team has more than the user's team */
|
||||
export const TENACITY_PLAYER_DEFICITS = [1, 2, 3] as const;
|
||||
|
||||
/** Special points Tenacity passively grants per second. Unaffected by Special Charge Up. */
|
||||
export const TENACITY_SPECIAL_POINTS_PER_SECOND: Record<
|
||||
TenacityPlayerDeficit,
|
||||
number
|
||||
> = {
|
||||
1: 3.26,
|
||||
2: 5.44,
|
||||
3: 7.59,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,10 @@ import type {
|
||||
SpecialWeaponId,
|
||||
SubWeaponId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { DAMAGE_TYPE } from "./analyzer-constants";
|
||||
import type {
|
||||
DAMAGE_TYPE,
|
||||
TENACITY_PLAYER_DEFICITS,
|
||||
} from "./analyzer-constants";
|
||||
import type { SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
import type { weaponParams } from "./data/weapon-params";
|
||||
|
||||
@@ -257,6 +260,8 @@ export interface FullInkTankOption {
|
||||
|
||||
export type DamageType = (typeof DAMAGE_TYPE)[number];
|
||||
|
||||
export type TenacityPlayerDeficit = (typeof TENACITY_PLAYER_DEFICITS)[number];
|
||||
|
||||
export interface Damage {
|
||||
value: number;
|
||||
type: DamageType;
|
||||
@@ -284,6 +289,8 @@ export interface AnalyzedBuild {
|
||||
specialPoint: Stat;
|
||||
specialLost: Stat;
|
||||
specialLostSplattedByRP: Stat;
|
||||
/** Seconds it takes Tenacity to fill the special gauge, keyed by how many active players the user's team is down. Only set if the build has Tenacity. */
|
||||
tenacitySecondsToSpecial?: Record<TenacityPlayerDeficit, number>;
|
||||
mainWeaponWhiteInkSeconds?: number;
|
||||
subWeaponWhiteInkSeconds: number;
|
||||
subWeaponInkConsumptionPercentage: Stat;
|
||||
|
||||
@@ -90,6 +90,58 @@ describe("Analyze build", () => {
|
||||
).toBeGreaterThan(analyzedJr.stats.subWeaponInkConsumptionPercentage.value);
|
||||
});
|
||||
|
||||
test("Tenacity special charge time is only calculated with Tenacity in the build", () => {
|
||||
const analyzed = buildStats({
|
||||
weaponSplId: 0,
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
const analyzedWithTenacity = buildStats({
|
||||
weaponSplId: 0,
|
||||
mainOnlyAbilities: ["T"],
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
expect(analyzed.stats.tenacitySecondsToSpecial).toBeUndefined();
|
||||
expect(analyzedWithTenacity.stats.tenacitySecondsToSpecial).toBeDefined();
|
||||
});
|
||||
|
||||
test("Tenacity special charge time is not affected by Special Charge Up", () => {
|
||||
const analyzed = buildStats({
|
||||
weaponSplId: 0,
|
||||
mainOnlyAbilities: ["T"],
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
const analyzedWithSCU = buildStats({
|
||||
weaponSplId: 0,
|
||||
abilityPoints: new Map([["SCU", 57]]),
|
||||
mainOnlyAbilities: ["T"],
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
analyzedWithSCU.stats.specialPoint.value,
|
||||
"Special Charge Up should lower the points needed for special",
|
||||
).toBeLessThan(analyzed.stats.specialPoint.value);
|
||||
expect(analyzedWithSCU.stats.tenacitySecondsToSpecial).toEqual(
|
||||
analyzed.stats.tenacitySecondsToSpecial,
|
||||
);
|
||||
});
|
||||
|
||||
test("Tenacity fills the special gauge faster the more players the team is down", () => {
|
||||
const analyzed = buildStats({
|
||||
weaponSplId: 0,
|
||||
mainOnlyAbilities: ["T"],
|
||||
hasTacticooler: false,
|
||||
});
|
||||
|
||||
const secondsToSpecial = analyzed.stats.tenacitySecondsToSpecial!;
|
||||
|
||||
expect(secondsToSpecial[2]).toBeLessThan(secondsToSpecial[1]);
|
||||
expect(secondsToSpecial[3]).toBeLessThan(secondsToSpecial[2]);
|
||||
});
|
||||
|
||||
const subPowerApToQuickSuperJumpAp = new Map([
|
||||
[0, 0],
|
||||
[3, 4],
|
||||
|
||||
@@ -28,6 +28,7 @@ import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
DAMAGE_TYPE,
|
||||
RAINMAKER_SPEED_PENALTY_MODIFIER,
|
||||
TENACITY_SPECIAL_POINTS_PER_SECOND,
|
||||
} from "../analyzer-constants";
|
||||
import type {
|
||||
AbilityPoints,
|
||||
@@ -38,6 +39,7 @@ import type {
|
||||
SpecialWeaponParams,
|
||||
StatFunctionInput,
|
||||
SubWeaponParams,
|
||||
TenacityPlayerDeficit,
|
||||
} from "../analyzer-types";
|
||||
import { INK_CONSUME_TYPES } from "../analyzer-types";
|
||||
import type { abilityValues as abilityValuesJson } from "../data/ability-values";
|
||||
@@ -109,6 +111,7 @@ export function buildStats({
|
||||
specialPoint: specialPoint(input),
|
||||
specialLost: specialLost(input),
|
||||
specialLostSplattedByRP: specialLost(input, true),
|
||||
tenacitySecondsToSpecial: tenacitySecondsToSpecial(input),
|
||||
fullInkTankOptions: fullInkTankOptions(input),
|
||||
damages: damages(input),
|
||||
specialWeaponDamages: specialWeaponDamages(input),
|
||||
@@ -210,6 +213,27 @@ function specialPoint({
|
||||
};
|
||||
}
|
||||
|
||||
function tenacitySecondsToSpecial({
|
||||
mainWeaponParams,
|
||||
mainOnlyAbilities,
|
||||
}: StatFunctionInput): AnalyzedBuild["stats"]["tenacitySecondsToSpecial"] {
|
||||
if (!mainOnlyAbilities.includes("T")) return;
|
||||
|
||||
// Special Charge Up does not affect the rate Tenacity fills the gauge at
|
||||
// so the unmodified amount of points needed is used here
|
||||
const secondsToSpecial = (playerDeficit: TenacityPlayerDeficit) =>
|
||||
roundToNDecimalPlaces(
|
||||
mainWeaponParams.SpecialPoint /
|
||||
TENACITY_SPECIAL_POINTS_PER_SECOND[playerDeficit],
|
||||
);
|
||||
|
||||
return {
|
||||
1: secondsToSpecial(1),
|
||||
2: secondsToSpecial(2),
|
||||
3: secondsToSpecial(3),
|
||||
};
|
||||
}
|
||||
|
||||
const OWN_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.225;
|
||||
const ENEMY_RESPAWN_PUNISHER_EXTRA_SPECIAL_LOST = 0.15;
|
||||
function specialLost(
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Placeholder } from "~/components/Placeholder";
|
||||
import { Table } from "~/components/Table";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { abilitiesShort } from "~/modules/in-game-lists/abilities";
|
||||
import type {
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
damageTypeToWeaponType,
|
||||
MAX_AP,
|
||||
MAX_LDE_INTENSITY,
|
||||
TENACITY_PLAYER_DEFICITS,
|
||||
} from "../analyzer-constants";
|
||||
import { useAnalyzeBuild } from "../analyzer-hooks";
|
||||
import type {
|
||||
@@ -154,6 +156,11 @@ function BuildAnalyzerPage() {
|
||||
const objectShredderSelected = build[2][0] === "OS" || build2[2][0] === "OS";
|
||||
const stealthJumpSelected = build[2][0] === "SJ" || build2[2][0] === "SJ";
|
||||
|
||||
// same for both builds as it only depends on the weapon
|
||||
const tenacitySecondsToSpecial =
|
||||
analyzed.stats.tenacitySecondsToSpecial ??
|
||||
analyzed2.stats.tenacitySecondsToSpecial;
|
||||
|
||||
const context = {
|
||||
isComparing: !buildIsEmpty(build) && !buildIsEmpty(build2),
|
||||
mainWeaponId,
|
||||
@@ -532,6 +539,27 @@ function BuildAnalyzerPage() {
|
||||
title={t("analyzer:stat.specialLostSplattedByRP")}
|
||||
suffix="%"
|
||||
/>
|
||||
{tenacitySecondsToSpecial
|
||||
? TENACITY_PLAYER_DEFICITS.map((playerDeficit) => (
|
||||
<StatCard
|
||||
key={`tenacitySecondsToSpecial-${playerDeficit}`}
|
||||
context={context}
|
||||
stat={tenacitySecondsToSpecial[playerDeficit]}
|
||||
title={t("analyzer:stat.tenacitySecondsToSpecial", {
|
||||
count: playerDeficit,
|
||||
})}
|
||||
suffix={t("analyzer:suffix.seconds")}
|
||||
staticValueAbility="T"
|
||||
popoverInfo={t(
|
||||
"analyzer:stat.tenacitySecondsToSpecial.explanation",
|
||||
{
|
||||
teamPlayerCount: FULL_GROUP_SIZE - playerDeficit,
|
||||
opponentPlayerCount: FULL_GROUP_SIZE,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{analyzed.stats.specialDurationInSeconds && (
|
||||
<StatCard
|
||||
context={context}
|
||||
@@ -1419,6 +1447,7 @@ function StatCard({
|
||||
suffix,
|
||||
popoverInfo,
|
||||
testId,
|
||||
staticValueAbility,
|
||||
context: { mainWeaponId, abilityPoints, abilityPoints2, isComparing },
|
||||
}: {
|
||||
title: string;
|
||||
@@ -1426,6 +1455,8 @@ function StatCard({
|
||||
suffix?: string;
|
||||
popoverInfo?: string;
|
||||
testId?: string;
|
||||
/** Ability the stat is tied to, shown for stats that have a static value */
|
||||
staticValueAbility?: AbilityType;
|
||||
context: {
|
||||
mainWeaponId: MainWeaponId;
|
||||
abilityPoints: AbilityPoints;
|
||||
@@ -1549,7 +1580,11 @@ function StatCard({
|
||||
</div>
|
||||
{/* always render this so it reserves space */}
|
||||
<div className={styles.statCardAbilityContainer}>
|
||||
{!isStaticValue && (
|
||||
{isStaticValue ? (
|
||||
staticValueAbility ? (
|
||||
<ModifiedByAbilities abilities={staticValueAbility} />
|
||||
) : null
|
||||
) : (
|
||||
<>
|
||||
<ModifiedByAbilities abilities={stat[0].modifiedBy} />
|
||||
<StatChartPopover
|
||||
|
||||
@@ -2,15 +2,15 @@ import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfNullish,
|
||||
parseParams,
|
||||
safeParseRequestFormData,
|
||||
} from "~/utils/remix.server";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { reportWinnersActionSchema } from "../calendar-schemas";
|
||||
import { reportWinnersFormSchema } from "../calendar-schemas";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async (args) => {
|
||||
@@ -19,15 +19,13 @@ export const action: ActionFunction = async (args) => {
|
||||
params: args.params,
|
||||
schema: idObject,
|
||||
});
|
||||
const parsedInput = await safeParseRequestFormData({
|
||||
const result = await parseFormData({
|
||||
request: args.request,
|
||||
schema: reportWinnersActionSchema,
|
||||
schema: reportWinnersFormSchema,
|
||||
});
|
||||
|
||||
if (!parsedInput.success) {
|
||||
return {
|
||||
errors: parsedInput.errors,
|
||||
};
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const event = notFoundIfNullish(await CalendarRepository.findById(params.id));
|
||||
@@ -42,15 +40,8 @@ export const action: ActionFunction = async (args) => {
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId: params.id,
|
||||
participantCount: parsedInput.data.participantCount,
|
||||
results: parsedInput.data.team.map((t) => ({
|
||||
teamName: t.teamName,
|
||||
placement: t.placement,
|
||||
players: t.players.map((p) => ({
|
||||
userId: typeof p === "string" ? null : p.id,
|
||||
name: typeof p === "string" ? p : null,
|
||||
})),
|
||||
})),
|
||||
participantCount: result.data.participantCount,
|
||||
results: result.data.teams,
|
||||
});
|
||||
|
||||
throw redirect(calendarEventPage(params.id));
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
import { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import {
|
||||
parseRequestPayload,
|
||||
parseSafeSearchParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { parseSafeSearchParams } from "~/utils/remix.server";
|
||||
import { calendarPage } from "~/utils/urls";
|
||||
import { dayMonthYear } from "~/utils/zod";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: calendarFiltersSearchParamsSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
await UserRepository.updateOwnPreferences({
|
||||
defaultCalendarFilters: data,
|
||||
defaultCalendarFilters: result.data,
|
||||
});
|
||||
|
||||
const parsedSearchParams = parseSafeSearchParams({
|
||||
|
||||
@@ -94,6 +94,8 @@ export const EXCLUDED_TAGS: Array<CalendarEventTag> = ["CARDS", "SR"];
|
||||
|
||||
export const CALENDAR_EVENT_RESULT = {
|
||||
MAX_PARTICIPANTS_COUNT: 1000,
|
||||
MAX_TEAMS_COUNT: 100,
|
||||
DEFAULT_PLAYERS_LENGTH: 4,
|
||||
MAX_PLAYERS_LENGTH: 8,
|
||||
MAX_TEAM_NAME_LENGTH: 100,
|
||||
MAX_TEAM_PLACEMENT: 256,
|
||||
|
||||
@@ -5,16 +5,17 @@ import {
|
||||
badges,
|
||||
checkboxGroup,
|
||||
customField,
|
||||
datetime,
|
||||
datetimeOptional,
|
||||
datetimeRequired,
|
||||
hidden,
|
||||
idConstantOptional,
|
||||
image,
|
||||
numberFieldOptional,
|
||||
select,
|
||||
selectDynamicOptional,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
@@ -23,7 +24,7 @@ import { bracketProgressionSchema } from "./calendar-schemas";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
|
||||
/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */
|
||||
const calendarEventDateField = datetimeRequired({
|
||||
const calendarEventDateField = datetime({
|
||||
label: "labels.date",
|
||||
min: calendarEventMinDate,
|
||||
max: calendarEventMaxDate,
|
||||
@@ -31,10 +32,10 @@ const calendarEventDateField = datetimeRequired({
|
||||
|
||||
export const calendarNewBaseSchema = z.object({
|
||||
// discriminates between a calendar event and a tournament; seeded from the loader, no visible control
|
||||
toToolsEnabled: customField({ initialValue: false }, z.boolean()),
|
||||
toToolsEnabled: hidden(z.boolean(), false),
|
||||
eventToEditId: idConstantOptional(),
|
||||
tournamentToCopyId: idConstantOptional(),
|
||||
name: textFieldRequired({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
minLength: CALENDAR_EVENT.NAME_MIN_LENGTH,
|
||||
maxLength: CALENDAR_EVENT.NAME_MAX_LENGTH,
|
||||
|
||||
@@ -9,8 +9,12 @@ import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import {
|
||||
array,
|
||||
checkboxGroup,
|
||||
customField,
|
||||
fieldset,
|
||||
numberField,
|
||||
numberFieldOptional,
|
||||
radioGroup,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
toggle,
|
||||
userSearchOptional,
|
||||
@@ -18,12 +22,10 @@ import {
|
||||
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
|
||||
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
actualNumber,
|
||||
gamesShortSchema,
|
||||
id,
|
||||
modeShortWithSpecial,
|
||||
safeJSONParse,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { CALENDAR_EVENT, CALENDAR_EVENT_RESULT } from "./calendar-constants";
|
||||
import * as CalendarEvent from "./core/CalendarEvent";
|
||||
@@ -181,67 +183,116 @@ export const calendarFiltersSearchParamsObject = z.object({
|
||||
.catch(CalendarEvent.defaultFilters()),
|
||||
});
|
||||
|
||||
const playersSchema = z
|
||||
.array(
|
||||
z.union([
|
||||
z.string().min(1).max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
|
||||
z.object({ id }),
|
||||
]),
|
||||
)
|
||||
.nonempty({ message: "forms.errors.emptyTeam" })
|
||||
const reportedPlayerSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("USER"), id: id.nullable() }),
|
||||
z.object({
|
||||
type: z.literal("NAME"),
|
||||
name: z
|
||||
.string()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH)
|
||||
.nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ReportedPlayer = z.infer<typeof reportedPlayerSchema>;
|
||||
|
||||
export const EMPTY_REPORTED_PLAYER: ReportedPlayer = { type: "USER", id: null };
|
||||
|
||||
type StoredReportedPlayer = { userId: number | null; name: string | null };
|
||||
|
||||
const reportedPlayersSchema = z
|
||||
.array(reportedPlayerSchema)
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH)
|
||||
.transform((players) =>
|
||||
players.flatMap((player): Array<StoredReportedPlayer> => {
|
||||
if (player.type === "USER") {
|
||||
return player.id === null ? [] : [{ userId: player.id, name: null }];
|
||||
}
|
||||
|
||||
return player.name ? [{ userId: null, name: player.name }] : [];
|
||||
}),
|
||||
)
|
||||
.refine((players) => players.length > 0, {
|
||||
message: "forms:errors.emptyTeam",
|
||||
})
|
||||
.refine(
|
||||
(val) => {
|
||||
const userIds = val.flatMap((user) =>
|
||||
typeof user === "string" ? [] : user.id,
|
||||
);
|
||||
(players) => {
|
||||
const userIds = players.flatMap((player) => player.userId ?? []);
|
||||
|
||||
return userIds.length === new Set(userIds).size;
|
||||
},
|
||||
{
|
||||
message: "forms.errors.duplicatePlayer",
|
||||
},
|
||||
{ message: "forms:errors.duplicatePlayer" },
|
||||
);
|
||||
|
||||
export const reportWinnersActionSchema = z.object({
|
||||
participantCount: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT),
|
||||
),
|
||||
team: z.preprocess(
|
||||
toArray,
|
||||
z
|
||||
.array(
|
||||
z.preprocess(
|
||||
safeJSONParse,
|
||||
z.object({
|
||||
teamName: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH),
|
||||
placement: z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT),
|
||||
),
|
||||
players: playersSchema,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.refine(
|
||||
(val) => val.length === new Set(val.map((team) => team.teamName)).size,
|
||||
{ message: "forms.errors.uniqueTeamName" },
|
||||
),
|
||||
),
|
||||
const reportedTeamFieldset = fieldset({
|
||||
fields: z.object({
|
||||
teamName: textField({
|
||||
label: "labels.teamName",
|
||||
maxLength: CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH,
|
||||
}),
|
||||
placement: numberField({
|
||||
label: "labels.placement",
|
||||
maxLength: String(CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT).length,
|
||||
}),
|
||||
players: customField(
|
||||
{
|
||||
initialValue: new Array(
|
||||
CALENDAR_EVENT_RESULT.DEFAULT_PLAYERS_LENGTH,
|
||||
).fill(EMPTY_REPORTED_PLAYER),
|
||||
},
|
||||
reportedPlayersSchema,
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const reportWinnersFormSchema = z
|
||||
.object({
|
||||
participantCount: numberField({
|
||||
label: "labels.participantCount",
|
||||
maxLength: String(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT).length,
|
||||
}),
|
||||
teams: array({
|
||||
label: "labels.teams",
|
||||
min: 1,
|
||||
max: CALENDAR_EVENT_RESULT.MAX_TEAMS_COUNT,
|
||||
field: reportedTeamFieldset,
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (
|
||||
data.participantCount < 1 ||
|
||||
data.participantCount > CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.numberOutOfRange",
|
||||
path: ["participantCount"],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, team] of data.teams.entries()) {
|
||||
if (
|
||||
team.placement < 1 ||
|
||||
team.placement > CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.numberOutOfRange",
|
||||
path: ["teams", index, "placement"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const teamNames = data.teams.map((team) => team.teamName);
|
||||
if (teamNames.length !== new Set(teamNames).size) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.uniqueTeamName",
|
||||
path: ["teams"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const bracketProgressionSchema = z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Form, useLoaderData } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { UserSearch } from "~/components/elements/UserSearch";
|
||||
import { FormErrors } from "~/components/FormErrors";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { CustomFieldRenderProps } from "~/form/FormField";
|
||||
import { useTranslatedTexts } from "~/form/fields/FormFieldWrapper";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import type { ArrayItemRenderContext } from "~/form/types";
|
||||
import { errorMessageId } from "~/form/utils";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { action } from "../actions/calendar.$id.report-winners.server";
|
||||
import { CALENDAR_EVENT_RESULT } from "../calendar-constants";
|
||||
import {
|
||||
EMPTY_REPORTED_PLAYER,
|
||||
type ReportedPlayer,
|
||||
reportWinnersFormSchema,
|
||||
} from "../calendar-schemas";
|
||||
import { loader } from "../loaders/calendar.$id.report-winners.server";
|
||||
|
||||
export { action, loader };
|
||||
@@ -22,290 +30,108 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
export default function ReportWinnersPage() {
|
||||
const { t } = useTranslation(["common", "calendar"]);
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<Form method="post" className="stack md-plus items-start">
|
||||
<h1 className="text-lg">
|
||||
{t("calendar:forms.reportResultsHeader", { eventName: data.name })}
|
||||
</h1>
|
||||
<ParticipantsCountInput />
|
||||
<FormMessage type="info">
|
||||
{t("calendar:forms.reportResultsInfo")}
|
||||
</FormMessage>
|
||||
<TeamInputs />
|
||||
<SendouButton type="submit" className="mt-4">
|
||||
{t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
<FormErrors namespace="calendar" />
|
||||
</Form>
|
||||
<SendouForm
|
||||
schema={reportWinnersFormSchema}
|
||||
title={t("calendar:forms.reportResultsHeader", {
|
||||
eventName: data.name,
|
||||
})}
|
||||
defaultValues={{
|
||||
participantCount: data.participantCount ?? undefined,
|
||||
teams: data.winners.map((team) => ({
|
||||
teamName: team.teamName,
|
||||
placement: team.placement,
|
||||
players: team.players.map(playerToFormValue),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="participantCount" />
|
||||
<FormMessage type="info">
|
||||
{t("calendar:forms.reportResultsInfo")}
|
||||
</FormMessage>
|
||||
<FormField name="teams">
|
||||
{({ itemName }: ArrayItemRenderContext) => (
|
||||
<div className="stack md">
|
||||
<FormField name={`${itemName}.teamName`} />
|
||||
<FormField name={`${itemName}.placement`} />
|
||||
<FormField name={`${itemName}.players`}>
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<PlayersFormField {...props} />
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsCountInput() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
type LoadedPlayer = Unpacked<
|
||||
Unpacked<SerializeFrom<typeof loader>["winners"]>["players"]
|
||||
>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="name" required>
|
||||
{t("forms.participantCount")}
|
||||
</Label>
|
||||
<input
|
||||
name="participantCount"
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT}
|
||||
defaultValue={data.participantCount ?? undefined}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
function playerToFormValue(player: LoadedPlayer): ReportedPlayer {
|
||||
return typeof player.id === "number"
|
||||
? { type: "USER", id: player.id }
|
||||
: { type: "NAME", name: player.name };
|
||||
}
|
||||
|
||||
function TeamInputs() {
|
||||
const { t } = useTranslation("calendar");
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [amountOfTeams, setAmountOfTeams] = React.useState(
|
||||
Math.max(data.winners.length, 1),
|
||||
);
|
||||
function PlayersFormField({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
}: CustomFieldRenderProps) {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const { translatedError } = useTranslatedTexts({ error });
|
||||
const players = value as Array<ReportedPlayer>;
|
||||
|
||||
const handleTeamDelete = () => {
|
||||
setAmountOfTeams(amountOfTeams - 1);
|
||||
const handlePlayerChange = (index: number, newPlayer: ReportedPlayer) => {
|
||||
onChange(players.map((player, i) => (i === index ? newPlayer : player)));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr className="w-full" />
|
||||
{new Array(amountOfTeams + 1).fill(null).map((_, i) => {
|
||||
// last team is hidden so we can save its state even if user removes a filled team
|
||||
const hidden = i === amountOfTeams;
|
||||
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<Team
|
||||
onRemoveTeam={
|
||||
i === amountOfTeams - 1 && amountOfTeams > 1
|
||||
? handleTeamDelete
|
||||
: undefined
|
||||
}
|
||||
hidden={hidden}
|
||||
initialPlacement={String(i + 1)}
|
||||
initialValues={data.winners[i]}
|
||||
/>
|
||||
{!hidden && <hr className="w-full" />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<SendouButton
|
||||
onPress={() => setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
|
||||
size="small"
|
||||
>
|
||||
{t("forms.team.add")}
|
||||
</SendouButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_PLAYER = { id: 0 } as const;
|
||||
|
||||
interface TeamResults {
|
||||
teamName: string;
|
||||
placement: string;
|
||||
players: Array<
|
||||
| {
|
||||
id: number;
|
||||
}
|
||||
| string
|
||||
>;
|
||||
}
|
||||
|
||||
function Team({
|
||||
onRemoveTeam,
|
||||
hidden,
|
||||
initialPlacement,
|
||||
initialValues,
|
||||
}: {
|
||||
onRemoveTeam?: () => void;
|
||||
hidden: boolean;
|
||||
initialPlacement: string;
|
||||
initialValues?: Unpacked<SerializeFrom<typeof loader>["winners"]>;
|
||||
}) {
|
||||
const { t } = useTranslation("calendar");
|
||||
const teamNameId = React.useId();
|
||||
const placementId = React.useId();
|
||||
|
||||
const [results, setResults] = React.useState<TeamResults>({
|
||||
teamName: initialValues?.teamName ?? "",
|
||||
placement: String(initialValues?.placement ?? initialPlacement),
|
||||
players: initialValues?.players
|
||||
? (initialValues.players.map((player) =>
|
||||
player.name ? player.name : player,
|
||||
) as TeamResults["players"])
|
||||
: [NEW_PLAYER, NEW_PLAYER, NEW_PLAYER, NEW_PLAYER],
|
||||
});
|
||||
|
||||
const handleTeamNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setResults({ ...results, teamName: e.target.value });
|
||||
};
|
||||
|
||||
const handlePlacementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setResults({ ...results, placement: e.target.value });
|
||||
};
|
||||
|
||||
const handleSetPlayers = React.useCallback(
|
||||
(action: React.SetStateAction<TeamResults["players"]>) => {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
players: typeof action === "function" ? action(prev.players) : action,
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
return (
|
||||
<div className={clsx("stack md items-start")}>
|
||||
<input
|
||||
type="hidden"
|
||||
name="team"
|
||||
value={JSON.stringify({
|
||||
...results,
|
||||
players: results.players.filter(
|
||||
(player) =>
|
||||
(typeof player === "string" && player !== "") ||
|
||||
(typeof player === "object" && player.id !== 0),
|
||||
),
|
||||
})}
|
||||
/>
|
||||
<div className="stack horizontal md flex-wrap">
|
||||
<div>
|
||||
<Label htmlFor={teamNameId}>{t("forms.team.name")}</Label>
|
||||
<input
|
||||
id={teamNameId}
|
||||
value={results.teamName}
|
||||
onChange={handleTeamNameChange}
|
||||
required
|
||||
maxLength={CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={placementId}>{t("forms.team.placing")}</Label>
|
||||
<input
|
||||
id={placementId}
|
||||
value={results.placement}
|
||||
type="number"
|
||||
onChange={handlePlacementChange}
|
||||
required
|
||||
max={CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Players players={results.players} setPlayers={handleSetPlayers} />
|
||||
{onRemoveTeam && (
|
||||
<SendouButton
|
||||
onPress={onRemoveTeam}
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
className="mt-4"
|
||||
>
|
||||
{t("forms.team.remove")}
|
||||
</SendouButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Players({
|
||||
players,
|
||||
setPlayers,
|
||||
}: {
|
||||
players: TeamResults["players"];
|
||||
setPlayers: React.Dispatch<React.SetStateAction<TeamResults["players"]>>;
|
||||
}) {
|
||||
const { t } = useTranslation("calendar");
|
||||
const handleAddPlayer = () => {
|
||||
setPlayers([...players, NEW_PLAYER]);
|
||||
};
|
||||
|
||||
const handleRemovePlayer = () => {
|
||||
setPlayers(players.slice(0, -1));
|
||||
};
|
||||
|
||||
const handlePlayerInputTypeChange = (index: number) => {
|
||||
const newPlayers = [...players];
|
||||
newPlayers[index] = typeof newPlayers[index] === "string" ? NEW_PLAYER : "";
|
||||
setPlayers(newPlayers);
|
||||
};
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(index: number, newValue: string | number) => {
|
||||
setPlayers((prev) => {
|
||||
const newPlayers = [...prev];
|
||||
newPlayers[index] =
|
||||
typeof newValue === "string" ? newValue : { id: newValue };
|
||||
return newPlayers;
|
||||
});
|
||||
},
|
||||
[setPlayers],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
{players.map((player, i) => {
|
||||
const formId = `player-${i + 1}`;
|
||||
const asPlainInput = typeof player === "string";
|
||||
|
||||
return (
|
||||
<div key={i}>
|
||||
<div className="stack horizontal md items-center mb-1">
|
||||
<label htmlFor={formId} className="mb-0">
|
||||
{t("forms.team.player.header", { number: i + 1 })}
|
||||
</label>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onPress={() => handlePlayerInputTypeChange(i)}
|
||||
>
|
||||
{asPlainInput
|
||||
? t("forms.team.player.addAsUser")
|
||||
: t("forms.team.player.addAsText")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
<PlayerInput
|
||||
formId={formId}
|
||||
player={player}
|
||||
index={i}
|
||||
asPlainInput={asPlainInput}
|
||||
onInputChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{players.map((player, i) => (
|
||||
<PlayerInput
|
||||
key={i}
|
||||
index={i}
|
||||
player={player}
|
||||
onPlayerChange={handlePlayerChange}
|
||||
/>
|
||||
))}
|
||||
{translatedError ? (
|
||||
<FormMessage type="error" id={errorMessageId(name)}>
|
||||
{translatedError}
|
||||
</FormMessage>
|
||||
) : null}
|
||||
<div className="stack horizontal sm mt-2">
|
||||
<SendouButton
|
||||
size="small"
|
||||
onPress={handleAddPlayer}
|
||||
variant="outlined"
|
||||
onPress={() => onChange([...players, EMPTY_REPORTED_PLAYER])}
|
||||
isDisabled={
|
||||
players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH
|
||||
}
|
||||
variant="outlined"
|
||||
>
|
||||
{t("forms.team.player.add")}
|
||||
</SendouButton>{" "}
|
||||
{t("calendar:forms.team.player.add")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="destructive"
|
||||
onPress={handleRemovePlayer}
|
||||
onPress={() => onChange(players.slice(0, -1))}
|
||||
isDisabled={players.length === 1}
|
||||
>
|
||||
{t("forms.team.player.remove")}
|
||||
{t("calendar:forms.team.player.remove")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,50 +139,59 @@ function Players({
|
||||
}
|
||||
|
||||
function PlayerInput({
|
||||
formId,
|
||||
player,
|
||||
index,
|
||||
asPlainInput,
|
||||
onInputChange,
|
||||
player,
|
||||
onPlayerChange,
|
||||
}: {
|
||||
formId: string;
|
||||
player: TeamResults["players"][number];
|
||||
index: number;
|
||||
asPlainInput: boolean;
|
||||
onInputChange: (index: number, newValue: string | number) => void;
|
||||
player: ReportedPlayer;
|
||||
onPlayerChange: (index: number, newPlayer: ReportedPlayer) => void;
|
||||
}) {
|
||||
const handlePlainChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onInputChange(index, e.target.value);
|
||||
},
|
||||
[index, onInputChange],
|
||||
);
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const id = React.useId();
|
||||
|
||||
const handleUserChange = React.useCallback(
|
||||
(newUser: { id: number } | null) => {
|
||||
if (!newUser) return;
|
||||
onInputChange(index, newUser.id);
|
||||
},
|
||||
[index, onInputChange],
|
||||
);
|
||||
|
||||
if (asPlainInput) {
|
||||
return (
|
||||
<input
|
||||
id={formId}
|
||||
value={player as string}
|
||||
onChange={handlePlainChange}
|
||||
max={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const asPlainInput = player.type === "NAME";
|
||||
const label = t("calendar:forms.team.player.header", { number: index + 1 });
|
||||
|
||||
return (
|
||||
<UserSearch
|
||||
id={formId}
|
||||
name="team-player"
|
||||
initialUserId={(player as { id: number }).id}
|
||||
onChange={handleUserChange}
|
||||
/>
|
||||
<div className="stack horizontal sm items-end">
|
||||
<div className="w-full">
|
||||
{player.type === "NAME" ? (
|
||||
<>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<input
|
||||
id={id}
|
||||
value={player.name ?? ""}
|
||||
onChange={(e) =>
|
||||
onPlayerChange(index, { type: "NAME", name: e.target.value })
|
||||
}
|
||||
maxLength={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<UserSearch
|
||||
label={label}
|
||||
initialUserId={player.id ?? undefined}
|
||||
onChange={(user) =>
|
||||
onPlayerChange(index, { type: "USER", id: user?.id ?? null })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onPress={() =>
|
||||
onPlayerChange(
|
||||
index,
|
||||
asPlainInput ? EMPTY_REPORTED_PLAYER : { type: "NAME", name: "" },
|
||||
)
|
||||
}
|
||||
>
|
||||
{asPlainInput
|
||||
? t("calendar:forms.team.player.addAsUser")
|
||||
: t("calendar:forms.team.player.addAsText")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from "zod";
|
||||
import {
|
||||
checkboxGroup,
|
||||
customField,
|
||||
datetime,
|
||||
datetimeOptional,
|
||||
datetimeRequired,
|
||||
dayMonthYearRequired,
|
||||
dayMonthYear,
|
||||
dualSelectOptional,
|
||||
image,
|
||||
numberFieldOptional,
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
selectDynamicOptional,
|
||||
selectOptional,
|
||||
stageSelect,
|
||||
textArea,
|
||||
textAreaOptional,
|
||||
textAreaRequired,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
timeRangeOptional,
|
||||
toggle,
|
||||
userSearchOptional,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
|
||||
export const formFieldsShowcaseSchema = z.object({
|
||||
// Text fields
|
||||
requiredText: textFieldRequired({
|
||||
requiredText: textField({
|
||||
label: "labels.name",
|
||||
maxLength: 100,
|
||||
}),
|
||||
@@ -39,7 +39,7 @@ export const formFieldsShowcaseSchema = z.object({
|
||||
}),
|
||||
|
||||
// Text areas
|
||||
requiredTextArea: textAreaRequired({
|
||||
requiredTextArea: textArea({
|
||||
label: "labels.description",
|
||||
maxLength: 500,
|
||||
}),
|
||||
@@ -119,13 +119,13 @@ export const formFieldsShowcaseSchema = z.object({
|
||||
}),
|
||||
|
||||
// Date & Time
|
||||
requiredDatetime: datetimeRequired({
|
||||
requiredDatetime: datetime({
|
||||
label: "labels.startTime",
|
||||
}),
|
||||
optionalDatetime: datetimeOptional({
|
||||
label: "labels.vodDate",
|
||||
}),
|
||||
birthDate: dayMonthYearRequired({
|
||||
birthDate: dayMonthYear({
|
||||
label: "labels.banUserExpiresAt",
|
||||
}),
|
||||
availableTime: timeRangeOptional({
|
||||
|
||||
@@ -2102,14 +2102,14 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
|
||||
<SendouForm
|
||||
schema={formFieldsShowcaseSchema}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
className="w-full"
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<div className="stack lg">
|
||||
<Divider smallText>Text Fields</Divider>
|
||||
|
||||
<ComponentRow label="textFieldRequired">
|
||||
<ComponentRow label="textField">
|
||||
<FormField name="requiredText" />
|
||||
</ComponentRow>
|
||||
|
||||
@@ -2123,7 +2123,7 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
|
||||
<Divider smallText>Text Areas</Divider>
|
||||
|
||||
<ComponentRow label="textAreaRequired">
|
||||
<ComponentRow label="textArea">
|
||||
<FormField name="requiredTextArea" />
|
||||
</ComponentRow>
|
||||
|
||||
@@ -2173,7 +2173,7 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
|
||||
<Divider smallText>Date & Time</Divider>
|
||||
|
||||
<ComponentRow label="datetimeRequired">
|
||||
<ComponentRow label="datetime">
|
||||
<FormField name="requiredDatetime" />
|
||||
</ComponentRow>
|
||||
|
||||
@@ -2181,7 +2181,7 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
<FormField name="optionalDatetime" />
|
||||
</ComponentRow>
|
||||
|
||||
<ComponentRow label="dayMonthYearRequired">
|
||||
<ComponentRow label="dayMonthYear">
|
||||
<FormField name="birthDate" />
|
||||
</ComponentRow>
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
SendouMenuItem,
|
||||
SendouMenuSection,
|
||||
} from "~/components/elements/Menu";
|
||||
import { TwitchIcon } from "~/components/icons/Twitch";
|
||||
import { ListButton } from "~/components/SideNav";
|
||||
import {
|
||||
type FriendActivityBadge,
|
||||
type FriendActivityType,
|
||||
isLiveFriendActivity,
|
||||
friendActivityBadge,
|
||||
} from "~/features/friends/friends-constants";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import {
|
||||
@@ -23,6 +25,11 @@ import {
|
||||
tournamentSubsPage,
|
||||
} from "~/utils/urls";
|
||||
|
||||
const ACTIVITY_BADGE_TRANSLATION_KEY = {
|
||||
MATCH: "friends:friendsList.inMatch",
|
||||
NEXT: "friends:friendsList.nextMatch",
|
||||
} as const satisfies Record<FriendActivityBadge, string>;
|
||||
|
||||
export function FriendMenu({
|
||||
discordId,
|
||||
discordAvatar,
|
||||
@@ -34,6 +41,7 @@ export function FriendMenu({
|
||||
activityType,
|
||||
matchId,
|
||||
tournamentId,
|
||||
streamUrl,
|
||||
friendshipId,
|
||||
friendshipCreatedAt,
|
||||
onNavigate,
|
||||
@@ -48,6 +56,7 @@ export function FriendMenu({
|
||||
activityType: FriendActivityType | null;
|
||||
matchId: number | null;
|
||||
tournamentId: number | null;
|
||||
streamUrl: string | null;
|
||||
friendshipId?: number;
|
||||
friendshipCreatedAt?: number | null;
|
||||
onNavigate?: () => void;
|
||||
@@ -67,7 +76,7 @@ export function FriendMenu({
|
||||
})
|
||||
: null;
|
||||
|
||||
const isLive = isLiveFriendActivity(activityType);
|
||||
const activityBadge = friendActivityBadge(activityType);
|
||||
const activity = resolveActivity({ activityType, matchId, tournamentId });
|
||||
|
||||
return (
|
||||
@@ -77,8 +86,14 @@ export function FriendMenu({
|
||||
<ListButton
|
||||
user={{ discordId, discordAvatar, customAvatarUrl }}
|
||||
subtitle={subtitle}
|
||||
badge={isLive ? t("friends:friendsList.live") : badge}
|
||||
badgeVariant={isLive ? "warning" : "default"}
|
||||
badge={
|
||||
streamUrl
|
||||
? t("friends:friendsList.live")
|
||||
: activityBadge
|
||||
? t(ACTIVITY_BADGE_TRANSLATION_KEY[activityBadge])
|
||||
: badge
|
||||
}
|
||||
badgeVariant={streamUrl ? "warning" : "default"}
|
||||
>
|
||||
{name}
|
||||
</ListButton>
|
||||
@@ -88,6 +103,17 @@ export function FriendMenu({
|
||||
<SendouMenuItem href={url} icon={<User />} onAction={onNavigate}>
|
||||
{t("friends:friendsList.viewUserPage")}
|
||||
</SendouMenuItem>
|
||||
{streamUrl ? (
|
||||
<SendouMenuItem
|
||||
href={streamUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
icon={<TwitchIcon />}
|
||||
onAction={onNavigate}
|
||||
>
|
||||
{t("friends:friendsList.watchStream")}
|
||||
</SendouMenuItem>
|
||||
) : null}
|
||||
{activity?.type === "join-sendouq" ? (
|
||||
<SendouMenuItem
|
||||
icon={<Swords />}
|
||||
@@ -189,7 +215,7 @@ function resolveActivity(friend: {
|
||||
}),
|
||||
} as const)
|
||||
: null;
|
||||
case "TOURNAMENT_PLAYING":
|
||||
case "TOURNAMENT_WAITING":
|
||||
return friend.tournamentId
|
||||
? ({
|
||||
type: "view-tournament",
|
||||
|
||||
@@ -8,30 +8,33 @@ export const SENDOUQ_ACTIVITY_LABEL = "SendouQ";
|
||||
export type FriendActivityType =
|
||||
| "SENDOUQ_MATCH"
|
||||
| "TOURNAMENT_MATCH"
|
||||
| "TOURNAMENT_PLAYING"
|
||||
| "TOURNAMENT_WAITING"
|
||||
| "SENDOUQ"
|
||||
| "TOURNAMENT_SUB";
|
||||
|
||||
/**
|
||||
* Whether the activity represents a friend currently playing (in a live match
|
||||
* or otherwise busy in a running tournament) as opposed to looking for members.
|
||||
*/
|
||||
export function isLiveFriendActivity(type: FriendActivityType | null) {
|
||||
return (
|
||||
type === "SENDOUQ_MATCH" ||
|
||||
type === "TOURNAMENT_MATCH" ||
|
||||
type === "TOURNAMENT_PLAYING"
|
||||
);
|
||||
export type FriendActivityBadge = "MATCH" | "NEXT";
|
||||
|
||||
const ACTIVITY_BADGE: Record<FriendActivityType, FriendActivityBadge | null> = {
|
||||
SENDOUQ_MATCH: "MATCH",
|
||||
TOURNAMENT_MATCH: "MATCH",
|
||||
TOURNAMENT_WAITING: "NEXT",
|
||||
SENDOUQ: null,
|
||||
TOURNAMENT_SUB: null,
|
||||
};
|
||||
|
||||
export function friendActivityBadge(type: FriendActivityType | null) {
|
||||
if (!type) return null;
|
||||
|
||||
return ACTIVITY_BADGE[type];
|
||||
}
|
||||
|
||||
export function isInProgressFriendActivity(type: FriendActivityType | null) {
|
||||
return friendActivityBadge(type) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort value used to order friends by how interesting their activity is.
|
||||
* Looking for members ranks highest (others can act on it), then live activity,
|
||||
* then no activity.
|
||||
*/
|
||||
export function friendActivitySortValue(type: FriendActivityType | null) {
|
||||
if (type === "SENDOUQ") return 4;
|
||||
if (type === "TOURNAMENT_SUB") return 3;
|
||||
if (isLiveFriendActivity(type)) return 2;
|
||||
if (isInProgressFriendActivity(type)) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { groupExpiryStatus } from "~/features/sendouq/core/groups";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
|
||||
import type { TournamentTeamMemberProgressStatus } from "~/features/tournament-bracket/core/Tournament";
|
||||
import type {
|
||||
Tournament,
|
||||
TournamentTeamMemberProgressStatus,
|
||||
} from "~/features/tournament-bracket/core/Tournament";
|
||||
import { twitchUrl } from "~/utils/urls";
|
||||
import {
|
||||
type FriendActivityType,
|
||||
SENDOUQ_ACTIVITY_LABEL,
|
||||
@@ -14,6 +19,8 @@ export interface FriendActivity {
|
||||
badge: string | null;
|
||||
matchId: number | null;
|
||||
tournamentId: number | null;
|
||||
/** Set when the friend's current match can be watched, making the activity show up as "LIVE". */
|
||||
streamUrl: string | null;
|
||||
}
|
||||
|
||||
const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
|
||||
@@ -25,15 +32,32 @@ const TOURNAMENT_STATUS_IS_IN_PROGRESS: Record<
|
||||
WAITING_FOR_CAST: true,
|
||||
WAITING_FOR_ROUND: true,
|
||||
WAITING_FOR_GROUPS: true,
|
||||
// to counter 2 day tournaments showing LIVE in between
|
||||
// to counter 2 day tournaments showing as in progress in between
|
||||
WAITING_FOR_BRACKET: false,
|
||||
CHECKIN: false,
|
||||
THANKS_FOR_PLAYING: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Twitch account streaming each ongoing SendouQ match, keyed by match id. Resolved
|
||||
* once per request as activity is resolved separately for every friend.
|
||||
*/
|
||||
export async function resolveSendouQMatchStreams() {
|
||||
const streams = await cachedStreams();
|
||||
|
||||
const result = new Map<number, string>();
|
||||
for (const { match, stream } of streams) {
|
||||
if (!stream.twitchUserName || result.has(match.id)) continue;
|
||||
|
||||
result.set(match.id, stream.twitchUserName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves what a friend is currently doing for display in the friends list,
|
||||
* prioritizing in-progress activity (a live SendouQ or tournament match) over
|
||||
* prioritizing in-progress activity (an ongoing SendouQ or tournament match) over
|
||||
* looking-for-members activity.
|
||||
*/
|
||||
export function resolveFriendActivity({
|
||||
@@ -42,22 +66,27 @@ export function resolveFriendActivity({
|
||||
tournamentName,
|
||||
teamMemberCount,
|
||||
tournamentMinTeamSize,
|
||||
sendouQMatchStreams,
|
||||
}: {
|
||||
friendId: number;
|
||||
tournamentId: number | null;
|
||||
tournamentName: string | null;
|
||||
teamMemberCount: number | null;
|
||||
tournamentMinTeamSize: number | null;
|
||||
sendouQMatchStreams: ReadonlyMap<number, string>;
|
||||
}): FriendActivity {
|
||||
const ownGroup = SendouQ.findOwnGroup(friendId);
|
||||
|
||||
if (ownGroup?.matchId) {
|
||||
const twitchAccount = sendouQMatchStreams.get(ownGroup.matchId);
|
||||
|
||||
return {
|
||||
type: "SENDOUQ_MATCH",
|
||||
subtitle: SENDOUQ_ACTIVITY_LABEL,
|
||||
badge: null,
|
||||
matchId: ownGroup.matchId,
|
||||
tournamentId: null,
|
||||
streamUrl: twitchAccount ? twitchUrl(twitchAccount) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +104,7 @@ export function resolveFriendActivity({
|
||||
badge: `${ownGroup.members.length}/${FULL_GROUP_SIZE}`,
|
||||
matchId: null,
|
||||
tournamentId: null,
|
||||
streamUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +115,7 @@ export function resolveFriendActivity({
|
||||
badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`,
|
||||
matchId: null,
|
||||
tournamentId,
|
||||
streamUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,6 +125,7 @@ export function resolveFriendActivity({
|
||||
badge: null,
|
||||
matchId: null,
|
||||
tournamentId: null,
|
||||
streamUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,14 +134,79 @@ function resolveTournamentActivity(friendId: number): FriendActivity | null {
|
||||
const status = tournament.teamMemberOfProgressStatus({ id: friendId });
|
||||
if (!status || !TOURNAMENT_STATUS_IS_IN_PROGRESS[status.type]) continue;
|
||||
|
||||
const isInMatch = status.type === "MATCH";
|
||||
|
||||
return {
|
||||
type: status.type === "MATCH" ? "TOURNAMENT_MATCH" : "TOURNAMENT_PLAYING",
|
||||
type: isInMatch ? "TOURNAMENT_MATCH" : "TOURNAMENT_WAITING",
|
||||
subtitle: tournament.ctx.name,
|
||||
badge: null,
|
||||
matchId: status.type === "MATCH" ? status.matchId : null,
|
||||
matchId: isInMatch ? status.matchId : null,
|
||||
tournamentId: tournament.ctx.id,
|
||||
streamUrl: isInMatch
|
||||
? tournamentStreamUrl({
|
||||
tournament,
|
||||
friendId,
|
||||
matchId: status.matchId,
|
||||
opponentId: status.opponentId,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the friend's ongoing tournament match can be watched, preferring the view
|
||||
* that shows the friend best: their own stream, then a teammate's stream, then the
|
||||
* official cast of the match and finally an opponent's.
|
||||
*/
|
||||
function tournamentStreamUrl({
|
||||
tournament,
|
||||
friendId,
|
||||
matchId,
|
||||
opponentId,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
friendId: number;
|
||||
matchId: number;
|
||||
opponentId: number;
|
||||
}) {
|
||||
const streamingParticipantIds = new Set(tournament.streamingParticipantIds);
|
||||
const ownTeamMembers =
|
||||
tournament.teamMemberOfByUser({ id: friendId })?.members ?? [];
|
||||
|
||||
const friendAccount = streamingTwitchAccount(
|
||||
ownTeamMembers.filter((member) => member.userId === friendId),
|
||||
streamingParticipantIds,
|
||||
);
|
||||
if (friendAccount) return twitchUrl(friendAccount);
|
||||
|
||||
const teammateAccount = streamingTwitchAccount(
|
||||
ownTeamMembers.filter((member) => member.userId !== friendId),
|
||||
streamingParticipantIds,
|
||||
);
|
||||
if (teammateAccount) return twitchUrl(teammateAccount);
|
||||
|
||||
const castAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find(
|
||||
(castedMatch) => castedMatch.matchId === matchId,
|
||||
)?.twitchAccount;
|
||||
if (castAccount) return twitchUrl(castAccount);
|
||||
|
||||
const opponentAccount = streamingTwitchAccount(
|
||||
tournament.teamById(opponentId)?.members ?? [],
|
||||
streamingParticipantIds,
|
||||
);
|
||||
|
||||
return opponentAccount ? twitchUrl(opponentAccount) : null;
|
||||
}
|
||||
|
||||
function streamingTwitchAccount(
|
||||
players: Array<{ userId: number; streamTwitch: string | null }>,
|
||||
streamingParticipantIds: ReadonlySet<number>,
|
||||
) {
|
||||
return players.find(
|
||||
(player) =>
|
||||
streamingParticipantIds.has(player.userId) && player.streamTwitch,
|
||||
)?.streamTwitch;
|
||||
}
|
||||
|
||||
@@ -3,19 +3,27 @@ import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import * as FriendRepository from "../FriendRepository.server";
|
||||
import { friendActivitySortValue } from "../friends-constants";
|
||||
import { resolveFriendActivity } from "../friends-utils.server";
|
||||
import {
|
||||
resolveFriendActivity,
|
||||
resolveSendouQMatchStreams,
|
||||
} from "../friends-utils.server";
|
||||
|
||||
export type FriendsLoaderData = typeof loader;
|
||||
|
||||
export const loader = async () => {
|
||||
const user = requireUser();
|
||||
|
||||
const [friendsWithActivity, pendingRequests, incomingRequests] =
|
||||
await Promise.all([
|
||||
FriendRepository.findByUserIdWithActivity(user.id),
|
||||
FriendRepository.findPendingSentRequests(user.id),
|
||||
FriendRepository.findPendingReceivedRequests(user.id),
|
||||
]);
|
||||
const [
|
||||
friendsWithActivity,
|
||||
pendingRequests,
|
||||
incomingRequests,
|
||||
streamedSendouQMatches,
|
||||
] = await Promise.all([
|
||||
FriendRepository.findByUserIdWithActivity(user.id),
|
||||
FriendRepository.findPendingSentRequests(user.id),
|
||||
FriendRepository.findPendingReceivedRequests(user.id),
|
||||
resolveSendouQMatchStreams(),
|
||||
]);
|
||||
|
||||
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
|
||||
|
||||
@@ -29,6 +37,7 @@ export const loader = async () => {
|
||||
tournamentName: friend.tournamentName,
|
||||
teamMemberCount: friend.teamMemberCount,
|
||||
tournamentMinTeamSize: friend.tournamentMinTeamSize,
|
||||
sendouQMatchStreams: streamedSendouQMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -47,6 +56,7 @@ export const loader = async () => {
|
||||
activityType: activity.type,
|
||||
matchId: activity.matchId,
|
||||
tournamentId: activity.tournamentId ?? friend.tournamentId,
|
||||
streamUrl: activity.streamUrl,
|
||||
friendshipCreatedAt: friend.friendshipCreatedAt,
|
||||
};
|
||||
}),
|
||||
@@ -64,6 +74,7 @@ export const loader = async () => {
|
||||
tournamentName: tm.tournamentName,
|
||||
teamMemberCount: tm.teamMemberCount,
|
||||
tournamentMinTeamSize: tm.tournamentMinTeamSize,
|
||||
sendouQMatchStreams: streamedSendouQMatches,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -81,6 +92,7 @@ export const loader = async () => {
|
||||
activityType: activity.type,
|
||||
matchId: activity.matchId,
|
||||
tournamentId: activity.tournamentId ?? tm.tournamentId,
|
||||
streamUrl: activity.streamUrl,
|
||||
};
|
||||
}),
|
||||
[(tm) => friendActivitySortValue(tm.activityType), "desc"],
|
||||
|
||||
53
app/features/img-upload/image-bytes.server.ts
Normal file
53
app/features/img-upload/image-bytes.server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
export type ImageExtension = "webp" | "png" | "jpeg";
|
||||
|
||||
/**
|
||||
* Decodes a base64 image data URL to a buffer, resolving the format from the buffer's own magic
|
||||
* bytes rather than trusting the client-declared mime type, and asserting it is one of
|
||||
* `allowedExtensions`.
|
||||
*/
|
||||
export function dataUrlToImageBuffer(
|
||||
dataUrl: string,
|
||||
allowedExtensions: ReadonlyArray<ImageExtension>,
|
||||
) {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
|
||||
const extension = imageExtensionFromMagicBytes(buffer);
|
||||
invariant(
|
||||
extension && allowedExtensions.includes(extension),
|
||||
`Submitted image is not a valid ${allowedExtensions.join(" or ")}`,
|
||||
);
|
||||
|
||||
return { buffer, extension };
|
||||
}
|
||||
|
||||
function imageExtensionFromMagicBytes(buffer: Buffer): ImageExtension | null {
|
||||
if (
|
||||
buffer.length > 12 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
|
||||
if (
|
||||
buffer.length > 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer.toString("ascii", 1, 4) === "PNG"
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
|
||||
if (
|
||||
buffer.length > 3 &&
|
||||
buffer[0] === 0xff &&
|
||||
buffer[1] === 0xd8 &&
|
||||
buffer[2] === 0xff
|
||||
) {
|
||||
return "jpeg";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { shortNanoid } from "~/utils/id";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import * as ImageRepository from "./ImageRepository.server";
|
||||
import { dataUrlToImageBuffer } from "./image-bytes.server";
|
||||
import { uploadStreamToS3 } from "./s3.server";
|
||||
import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants";
|
||||
|
||||
@@ -44,7 +45,11 @@ export async function imageFieldValueToImgId({
|
||||
);
|
||||
}
|
||||
|
||||
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl);
|
||||
// the client compresses to webp, but browsers without canvas webp encoding fall back to png
|
||||
const { buffer, extension } = dataUrlToImageBuffer(value.dataUrl, [
|
||||
"webp",
|
||||
"png",
|
||||
]);
|
||||
|
||||
const uploadedFileLocation = await uploadStreamToS3(
|
||||
Readable.from(buffer),
|
||||
@@ -63,37 +68,3 @@ export async function imageFieldValueToImgId({
|
||||
|
||||
return img.id;
|
||||
}
|
||||
|
||||
function dataUrlToImageBuffer(dataUrl: string) {
|
||||
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
|
||||
const extension = imageExtensionFromMagicBytes(buffer);
|
||||
invariant(extension, "Submitted image is not a valid webp or png");
|
||||
|
||||
return { buffer, extension };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the image format from the buffer's magic bytes. The client compresses to webp,
|
||||
* but browsers without canvas webp encoding silently fall back to png.
|
||||
*/
|
||||
function imageExtensionFromMagicBytes(buffer: Buffer): "webp" | "png" | null {
|
||||
if (
|
||||
buffer.length > 12 &&
|
||||
buffer.toString("ascii", 0, 4) === "RIFF" &&
|
||||
buffer.toString("ascii", 8, 12) === "WEBP"
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
|
||||
if (
|
||||
buffer.length > 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer.toString("ascii", 1, 4) === "PNG"
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const ALLOWED_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "webp"];
|
||||
|
||||
export const MAX_UNVALIDATED_IMG_COUNT = 5;
|
||||
|
||||
export const IMAGES_TO_VALIDATE_AT_ONCE = 5;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
idConstantOptional,
|
||||
selectDynamic,
|
||||
selectDynamicOptional,
|
||||
textAreaRequired,
|
||||
textArea,
|
||||
} from "~/form/fields";
|
||||
import { LFG, TIMEZONES } from "./lfg-constants";
|
||||
|
||||
@@ -14,7 +14,7 @@ export const lfgNewSchema = z
|
||||
postId: idConstantOptional(),
|
||||
type: selectDynamic({ label: "labels.type" }),
|
||||
timezone: selectDynamic({ label: "labels.timezone" }),
|
||||
postText: textAreaRequired({
|
||||
postText: textArea({
|
||||
label: "labels.text",
|
||||
maxLength: LFG.MAX_TEXT_LENGTH,
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
idConstant,
|
||||
selectDynamic,
|
||||
stringConstant,
|
||||
textAreaRequired,
|
||||
textArea,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { _action, actualNumber } from "~/utils/zod";
|
||||
@@ -12,13 +12,13 @@ import { PLUS_TIERS } from "./plus-suggestions-constants";
|
||||
export const followUpCommentFormSchema = z.object({
|
||||
tier: idConstant(),
|
||||
suggestedId: idConstant(),
|
||||
comment: textAreaRequired({
|
||||
comment: textArea({
|
||||
label: "labels.comment",
|
||||
maxLength: 280,
|
||||
}),
|
||||
});
|
||||
|
||||
const suggestionTextFormFieldSchema = textAreaRequired({
|
||||
const suggestionTextFormFieldSchema = textArea({
|
||||
label: "labels.comment",
|
||||
maxLength: 500,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
notFoundIfNullish,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "~/utils/zod";
|
||||
@@ -26,11 +26,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const post = notFoundIfNullish(await ScrimPostRepository.findById(id));
|
||||
const user = requireUser();
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: scrimIdActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
requirePermission(post, "MANAGE_TRACKING");
|
||||
|
||||
switch (data._action) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import { datePlaceholder } from "~/features/chat/chat-utils";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
@@ -18,29 +19,31 @@ import {
|
||||
DuplicateEntryError,
|
||||
} from "~/utils/errors";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
actionError,
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { navIconUrl, scrimPage, scrimsPage } from "~/utils/urls";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
import * as ScrimPostRepository from "../ScrimPostRepository.server";
|
||||
import { SCRIM } from "../scrims-constants";
|
||||
import { type newRequestSchema, scrimsActionSchema } from "../scrims-schemas";
|
||||
import { scrimsActionSchema } from "../scrims-schemas";
|
||||
import { generateTimeOptions } from "../scrims-utils";
|
||||
import { usersListForPost } from "./scrims.new.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: scrimsActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_POST": {
|
||||
const post = await findPost({
|
||||
@@ -75,10 +78,9 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
if (post.rangeEndsAt && !data.at) {
|
||||
return actionError<typeof newRequestSchema>({
|
||||
msg: "Please select a time for the scrim",
|
||||
field: "at",
|
||||
});
|
||||
return {
|
||||
fieldErrors: { at: "Please select a time for the scrim" },
|
||||
};
|
||||
}
|
||||
|
||||
if (post.rangeEndsAt && data.at) {
|
||||
@@ -89,10 +91,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const requestTime = data.at.getTime();
|
||||
|
||||
if (!validTimeOptions.includes(requestTime)) {
|
||||
return actionError<typeof newRequestSchema>({
|
||||
msg: "Selected time must be one of the available options",
|
||||
field: "at",
|
||||
});
|
||||
return {
|
||||
fieldErrors: {
|
||||
at: "Selected time must be one of the available options",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import { SendouDatePicker } from "~/components/elements/DatePicker";
|
||||
import { TournamentSearch } from "~/components/elements/TournamentSearch";
|
||||
import { Label } from "~/components/Label";
|
||||
import type { CustomFieldRenderProps } from "~/form";
|
||||
import { FormField } from "~/form/FormField";
|
||||
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { errorMessageId } from "~/form/utils";
|
||||
@@ -90,7 +90,7 @@ export default function NewScrimPage() {
|
||||
|
||||
<FormField name="maps" />
|
||||
|
||||
<TournamentSearchFormField />
|
||||
<MapsTournamentFormField />
|
||||
|
||||
<FormField name="postText" />
|
||||
|
||||
@@ -272,14 +272,9 @@ function AssociationSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentSearchFormField() {
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const { values, setValue, clientErrors, serverErrors } =
|
||||
useFormFieldContext();
|
||||
function MapsTournamentFormField() {
|
||||
const { values, setValue } = useFormFieldContext();
|
||||
const maps = values.maps as string;
|
||||
const mapsTournamentId = values.mapsTournamentId as number | null;
|
||||
|
||||
const error = serverErrors.mapsTournamentId ?? clientErrors.mapsTournamentId;
|
||||
|
||||
const prevMaps = React.useRef(maps);
|
||||
React.useEffect(() => {
|
||||
@@ -293,19 +288,5 @@ function TournamentSearchFormField() {
|
||||
|
||||
if (maps !== "TOURNAMENT") return null;
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
id="mapsTournamentId"
|
||||
name="mapsTournamentId"
|
||||
error={error}
|
||||
>
|
||||
<TournamentSearch
|
||||
label={t("scrims:forms.mapsTournament.title")}
|
||||
initialTournamentId={mapsTournamentId ?? undefined}
|
||||
onChange={(tournament) =>
|
||||
setValue("mapsTournamentId", tournament?.id ?? null)
|
||||
}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
return <FormField name="mapsTournamentId" />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { add, sub } from "date-fns";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
customField,
|
||||
datetimeRequired,
|
||||
datetime,
|
||||
dualSelectOptional,
|
||||
idConstant,
|
||||
radioGroupDynamic,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
selectOptional,
|
||||
stageSelect,
|
||||
stringConstant,
|
||||
textArea,
|
||||
textAreaOptional,
|
||||
textAreaRequired,
|
||||
textFieldOptional,
|
||||
timeRangeOptional,
|
||||
toggle,
|
||||
@@ -79,7 +79,7 @@ const cancelRequestSchema = z.object({
|
||||
|
||||
export const cancelScrimFormSchema = z.object({
|
||||
_action: stringConstant("CANCEL_SCRIM"),
|
||||
reason: textAreaRequired({
|
||||
reason: textArea({
|
||||
label: "labels.scrimCancelReason",
|
||||
bottomText: "bottomTexts.scrimCancelReasonHelp",
|
||||
maxLength: SCRIM.CANCEL_REASON_MAX_LENGTH,
|
||||
@@ -309,7 +309,7 @@ const mapsItems = [
|
||||
|
||||
export const scrimsNewFormSchema = z
|
||||
.object({
|
||||
at: datetimeRequired({
|
||||
at: datetime({
|
||||
label: "labels.start",
|
||||
bottomText: "bottomTexts.scrimStart",
|
||||
min: () => sub(new Date(), { days: 1 }),
|
||||
@@ -357,10 +357,9 @@ export const scrimsNewFormSchema = z
|
||||
label: "labels.scrimMaps",
|
||||
items: [...mapsItems],
|
||||
}),
|
||||
mapsTournamentId: customField(
|
||||
{ initialValue: null },
|
||||
z.preprocess(falsyToNull, id.nullable()),
|
||||
),
|
||||
mapsTournamentId: tournamentSearchOptional({
|
||||
label: "labels.scrimMapsTournament",
|
||||
}),
|
||||
})
|
||||
.superRefine((post, ctx) => {
|
||||
if (post.maps === "TOURNAMENT" && !post.mapsTournamentId) {
|
||||
|
||||
@@ -42,7 +42,10 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
: groupNames.bravo
|
||||
: undefined;
|
||||
|
||||
const bottomRow = (
|
||||
const awaitingConfirmation =
|
||||
!data.match.isLocked && SendouQMatch.score(data.match).isDecisive;
|
||||
|
||||
const bottomRow = data.match.isLocked ? null : (
|
||||
<MatchBannerBottomRow
|
||||
games={data.match.mapList.map((map) => ({
|
||||
mode: map.mode,
|
||||
@@ -54,9 +57,6 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
/>
|
||||
);
|
||||
|
||||
const awaitingConfirmation =
|
||||
!data.match.isLocked && SendouQMatch.score(data.match).isDecisive;
|
||||
|
||||
if (data.match.isLocked || awaitingConfirmation) {
|
||||
const playedStageIds = data.match.mapList
|
||||
.filter((m) => m.winnerGroupId !== null)
|
||||
@@ -154,7 +154,14 @@ function SendouQMatchBannerTopRow({
|
||||
}}
|
||||
>
|
||||
{data.match.isLocked || awaitingConfirmation ? (
|
||||
<MatchBannerStartedAt time={startedAt} />
|
||||
<MatchBannerStartedAt
|
||||
time={startedAt}
|
||||
endTime={
|
||||
lastMapReportedAt
|
||||
? databaseTimestampToDate(lastMapReportedAt)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MatchBannerTimer
|
||||
time={{
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
} from "~/features/sendouq-match/core/match.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
import { groupAfterMorph } from "../core/groups";
|
||||
@@ -25,10 +26,17 @@ import { SendouQError, setGroupChatMetadata } from "../q-utils.server";
|
||||
// and when we return null we just force a refresh
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const currentGroup = SendouQ.findOwnGroup(user.id);
|
||||
if (!currentGroup) return null;
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
SUSPENDED_PAGE,
|
||||
} from "~/utils/urls";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import {
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
@@ -30,11 +32,17 @@ import {
|
||||
|
||||
export const action: ActionFunction = async ({ request, url }) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: frontPageSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
try {
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
@@ -140,13 +148,15 @@ export const action: ActionFunction = async ({ request, url }) => {
|
||||
"Friend code already set",
|
||||
);
|
||||
|
||||
const friendCode = normalizeFriendCode(data.friendCode);
|
||||
|
||||
const isTakenFriendCode = (
|
||||
await UserRepository.findAllCurrentFriendCodes()
|
||||
).has(data.friendCode);
|
||||
).has(friendCode);
|
||||
|
||||
await UserRepository.insertFriendCode({
|
||||
userId: user.id,
|
||||
friendCode: data.friendCode,
|
||||
friendCode,
|
||||
submitterUserId: user.id,
|
||||
});
|
||||
|
||||
|
||||
@@ -81,10 +81,6 @@
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.noteTextarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.futureMatchMode {
|
||||
border-radius: 100%;
|
||||
background-color: var(--color-bg);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
@@ -34,7 +35,8 @@ import type {
|
||||
SQGroupMember,
|
||||
SQOwnGroup,
|
||||
} from "../core/SendouQ.server";
|
||||
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
import { updateGroupNoteSchema } from "../q-schemas";
|
||||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
import styles from "./GroupCard.module.css";
|
||||
|
||||
@@ -364,10 +366,6 @@ function MemberNote({
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setEditing(false);
|
||||
}, [note]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<AddPrivateNoteForm note={note} stopEditing={() => setEditing(false)} />
|
||||
@@ -408,31 +406,18 @@ function AddPrivateNoteForm({
|
||||
note?: string | null;
|
||||
stopEditing: () => void;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [value, setValue] = React.useState(note ?? "");
|
||||
|
||||
const newValueLegal = value.length <= SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!textareaRef.current) return;
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.selectionStart = textareaRef.current.selectionEnd =
|
||||
textareaRef.current.value.length;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
rows={2}
|
||||
className={`${styles.noteTextarea} mt-1`}
|
||||
name="value"
|
||||
ref={textareaRef}
|
||||
/>
|
||||
<div className="stack horizontal justify-between">
|
||||
<SendouForm
|
||||
schema={updateGroupNoteSchema}
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
defaultValues={{ value: note ?? "" }}
|
||||
className="stack sm mt-1"
|
||||
submitButtonText={t("common:actions.save")}
|
||||
submitButtonVariant="minimal"
|
||||
submitButtonSize="miniscule"
|
||||
secondarySubmit={
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
@@ -440,21 +425,11 @@ function AddPrivateNoteForm({
|
||||
>
|
||||
{t("common:actions.cancel")}
|
||||
</SendouButton>
|
||||
{newValueLegal ? (
|
||||
<SubmitButton
|
||||
_action="UPDATE_NOTE"
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<span className="text-warning text-xxs font-semi-bold">
|
||||
{value.length}/{SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
}
|
||||
onSuccess={stopEditing}
|
||||
>
|
||||
{({ FormField }) => <FormField name="value" autoFocus />}
|
||||
</SendouForm>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ export const SENDOUQ = {
|
||||
PRIVATE_USER_NOTE_MAX_LENGTH: 280,
|
||||
} as const;
|
||||
|
||||
export const FRIEND_CODE_REGEXP_PATTERN =
|
||||
"^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$";
|
||||
const FRIEND_CODE_REGEXP_PATTERN = "^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$";
|
||||
export const FRIEND_CODE_REGEXP = new RegExp(FRIEND_CODE_REGEXP_PATTERN);
|
||||
|
||||
/** Length of a friend code with the optional "SW-" prefix included */
|
||||
export const FRIEND_CODE_MAX_LENGTH = 17;
|
||||
|
||||
export const FULL_GROUP_SIZE = 4;
|
||||
|
||||
export const SENDOUQ_BEST_OF = 7;
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
_action,
|
||||
deduplicate,
|
||||
falsyToNull,
|
||||
friendCode,
|
||||
id,
|
||||
modeShort,
|
||||
stageId,
|
||||
} from "~/utils/zod";
|
||||
import { SENDOUQ } from "./q-constants";
|
||||
import { _action, deduplicate, id, modeShort, stageId } from "~/utils/zod";
|
||||
import { addFriendCodeSchema, updateGroupNoteSchema } from "./q-schemas";
|
||||
|
||||
export const frontPageSchema = z.union([
|
||||
z.object({
|
||||
@@ -18,10 +10,7 @@ export const frontPageSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("JOIN_TEAM"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ADD_FRIEND_CODE"),
|
||||
friendCode,
|
||||
}),
|
||||
addFriendCodeSchema,
|
||||
]);
|
||||
|
||||
export const preparingSchema = z.union([
|
||||
@@ -73,13 +62,7 @@ export const lookingSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("REFRESH_GROUP"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_NOTE"),
|
||||
value: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
}),
|
||||
updateGroupNoteSchema,
|
||||
]);
|
||||
|
||||
export const weaponUsageSearchParamsSchema = z.object({
|
||||
|
||||
35
app/features/sendouq/q-schemas.ts
Normal file
35
app/features/sendouq/q-schemas.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
import { stringConstant, textAreaOptional, textField } from "~/form/fields";
|
||||
import {
|
||||
FRIEND_CODE_MAX_LENGTH,
|
||||
FRIEND_CODE_REGEXP,
|
||||
SENDOUQ,
|
||||
} from "./q-constants";
|
||||
|
||||
export const updateGroupNoteSchema = z.object({
|
||||
_action: stringConstant("UPDATE_NOTE"),
|
||||
value: textAreaOptional({
|
||||
label: "labels.note",
|
||||
maxLength: SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Friend code as the user typed it, with the "SW-" prefix and dashes optional.
|
||||
* Pass through `normalizeFriendCode` before storing it.
|
||||
*/
|
||||
export const friendCodeField = textField({
|
||||
label: "labels.friendCode",
|
||||
maxLength: FRIEND_CODE_MAX_LENGTH,
|
||||
leftAddon: "SW-",
|
||||
placeholder: "placeholders.friendCode",
|
||||
regExp: {
|
||||
pattern: FRIEND_CODE_REGEXP,
|
||||
message: "forms:errors.invalidFriendCode",
|
||||
},
|
||||
});
|
||||
|
||||
export const addFriendCodeSchema = z.object({
|
||||
_action: stringConstant("ADD_FRIEND_CODE"),
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
@@ -3,20 +3,27 @@ import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { errorToast } from "~/utils/remix.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { settingsActionSchema } from "../settings-schemas.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: settingsActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "UPDATE_CUSTOM_THEME": {
|
||||
if (!isSupporter(user)) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export function LocaleTab() {
|
||||
defaultValues={{
|
||||
newValue: user.preferences.clockFormat ?? "auto",
|
||||
}}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
revalidateRoot
|
||||
fullWidth
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ export function PreferencesTab() {
|
||||
defaultValues={{
|
||||
newValue: user.preferences.disableBuildAbilitySorting ?? false,
|
||||
}}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
revalidateRoot
|
||||
fullWidth
|
||||
>
|
||||
@@ -40,7 +40,7 @@ export function PreferencesTab() {
|
||||
newValue:
|
||||
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
|
||||
}}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
revalidateRoot
|
||||
fullWidth
|
||||
>
|
||||
@@ -51,7 +51,7 @@ export function PreferencesTab() {
|
||||
defaultValues={{
|
||||
newValue: user.preferences.spoilerFreeMode ?? false,
|
||||
}}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
revalidateRoot
|
||||
fullWidth
|
||||
>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { customField, select, stringConstant, toggle } from "~/form/fields";
|
||||
import { hidden, select, stringConstant, toggle } from "~/form/fields";
|
||||
import { themeInputSchema } from "~/utils/zod";
|
||||
|
||||
const customThemeSchema = z.object({
|
||||
_action: stringConstant("UPDATE_CUSTOM_THEME"),
|
||||
newValue: customField({ initialValue: null }, themeInputSchema.nullable()),
|
||||
newValue: hidden(themeInputSchema.nullable(), null),
|
||||
});
|
||||
|
||||
export const clockFormatSchema = z.object({
|
||||
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import {
|
||||
type FriendActivityType,
|
||||
isLiveFriendActivity,
|
||||
isInProgressFriendActivity,
|
||||
} from "~/features/friends/friends-constants";
|
||||
import {
|
||||
type FriendActivity,
|
||||
resolveFriendActivity,
|
||||
resolveSendouQMatchStreams,
|
||||
} from "~/features/friends/friends-utils.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as LiveStreamRepository from "~/features/live-streams/LiveStreamRepository.server";
|
||||
@@ -60,6 +61,7 @@ export type SidebarFriend = {
|
||||
activityType: FriendActivityType | null;
|
||||
matchId: number | null;
|
||||
tournamentId: number | null;
|
||||
streamUrl: string | null;
|
||||
};
|
||||
|
||||
const MAX_EVENTS_VISIBLE = 5;
|
||||
@@ -86,12 +88,14 @@ export async function resolveSidebarData(userId: number | null) {
|
||||
friendsWithActivity,
|
||||
savedTournaments,
|
||||
incomingFriendRequestIds,
|
||||
streamedSendouQMatches,
|
||||
] = await Promise.all([
|
||||
ShowcaseTournaments.categorizedTournamentsByUserId(userId),
|
||||
ScrimPostRepository.findUserScrims(userId),
|
||||
FriendRepository.findByUserIdWithActivity(userId),
|
||||
SavedCalendarEventRepository.findAllUpcomingByUserId(userId),
|
||||
FriendRepository.findPendingReceivedRequestIds(userId),
|
||||
resolveSendouQMatchStreams(),
|
||||
]);
|
||||
|
||||
const seenTournamentIds = new Set<number>();
|
||||
@@ -119,7 +123,7 @@ export async function resolveSidebarData(userId: number | null) {
|
||||
.sort((a, b) => a.startsAt - b.startsAt)
|
||||
.slice(0, MAX_EVENTS_VISIBLE);
|
||||
|
||||
const friends = resolveFriends(friendsWithActivity);
|
||||
const friends = resolveFriends(friendsWithActivity, streamedSendouQMatches);
|
||||
|
||||
const savedTournamentIds = savedTournaments.map((t) => t.id);
|
||||
|
||||
@@ -277,7 +281,20 @@ type FriendWithActivity = Awaited<
|
||||
ReturnType<typeof FriendRepository.findByUserIdWithActivity>
|
||||
>[number];
|
||||
|
||||
function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
function resolveFriends(
|
||||
friendsWithActivity: FriendWithActivity[],
|
||||
streamedSendouQMatches: ReadonlyMap<number, string>,
|
||||
) {
|
||||
const activityForRow = (row: FriendWithActivity) =>
|
||||
resolveFriendActivity({
|
||||
friendId: row.id,
|
||||
tournamentId: row.tournamentId,
|
||||
tournamentName: row.tournamentName,
|
||||
teamMemberCount: row.teamMemberCount,
|
||||
tournamentMinTeamSize: row.tournamentMinTeamSize,
|
||||
sendouQMatchStreams: streamedSendouQMatches,
|
||||
});
|
||||
|
||||
const unique = R.uniqueBy(friendsWithActivity, (f) => f.id);
|
||||
const friendRows = unique.filter((f) => f.friendshipId !== null);
|
||||
const teamMemberRows = unique.filter((f) => f.friendshipId === null);
|
||||
@@ -297,7 +314,7 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
|
||||
const sidebarFriend = rowToSidebarFriend(friend, activity);
|
||||
|
||||
if (isLiveFriendActivity(activity.type)) {
|
||||
if (isInProgressFriendActivity(activity.type)) {
|
||||
activeFriends.push(sidebarFriend);
|
||||
} else if (activity.type === "SENDOUQ") {
|
||||
sendouqFriends.push(sidebarFriend);
|
||||
@@ -360,16 +377,6 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
|
||||
return result.slice(0, MAX_FRIENDS_VISIBLE);
|
||||
}
|
||||
|
||||
function activityForRow(row: FriendWithActivity): FriendActivity {
|
||||
return resolveFriendActivity({
|
||||
friendId: row.id,
|
||||
tournamentId: row.tournamentId,
|
||||
tournamentName: row.tournamentName,
|
||||
teamMemberCount: row.teamMemberCount,
|
||||
tournamentMinTeamSize: row.tournamentMinTeamSize,
|
||||
});
|
||||
}
|
||||
|
||||
function rowToSidebarFriend(
|
||||
row: FriendWithActivity,
|
||||
activity: FriendActivity | null,
|
||||
@@ -386,6 +393,7 @@ function rowToSidebarFriend(
|
||||
activityType: activity?.type ?? null,
|
||||
matchId: activity?.matchId ?? null,
|
||||
tournamentId: activity?.tournamentId ?? row.tournamentId,
|
||||
streamUrl: activity?.streamUrl ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
selectOptional,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
@@ -26,7 +26,7 @@ const teamNameValidate = {
|
||||
} as const;
|
||||
|
||||
export const createTeamSchema = z.object({
|
||||
name: textFieldRequired({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
minLength: TEAM.NAME_MIN_LENGTH,
|
||||
maxLength: TEAM.NAME_MAX_LENGTH,
|
||||
@@ -36,7 +36,7 @@ export const createTeamSchema = z.object({
|
||||
|
||||
export const editTeamFormSchema = z.object({
|
||||
_action: stringConstant("EDIT"),
|
||||
name: textFieldRequired({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
bottomText: "bottomTexts.name",
|
||||
minLength: TEAM.NAME_MIN_LENGTH,
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { TOURNAMENT_STAFF_ROLES } from "~/features/tournament/tournament-constants";
|
||||
import {
|
||||
array,
|
||||
fieldset,
|
||||
select,
|
||||
textFieldRequired,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { array, fieldset, select, textField, userSearch } from "~/form/fields";
|
||||
|
||||
export const adminStreamFormSchema = z.object({
|
||||
castTwitchAccounts: array({
|
||||
label: "labels.castTwitchAccounts",
|
||||
bottomText: "bottomTexts.castTwitchAccounts",
|
||||
max: 5,
|
||||
field: textFieldRequired({
|
||||
field: textField({
|
||||
maxLength: 100,
|
||||
placeholder: "placeholders.castTwitchAccounts",
|
||||
}),
|
||||
|
||||
@@ -1392,6 +1392,14 @@ describe("bracketIdxsForStandings", () => {
|
||||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when SE is the source
|
||||
});
|
||||
|
||||
it("does not treat a bracket as intermediate just because an underground bracket sources from it", () => {
|
||||
expect(
|
||||
Progression.bracketIdxsForStandings(
|
||||
progressions.swissToTwoSingleEliminationsWithUnderground,
|
||||
),
|
||||
).toEqual([1, 2, 0]); // missing 3 because it's underground
|
||||
});
|
||||
});
|
||||
|
||||
describe("startingBrackets", () => {
|
||||
|
||||
@@ -931,11 +931,18 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) {
|
||||
const bracketsToConsider = bracketsReachableFrom(0, progression);
|
||||
|
||||
const withoutIntermediateBrackets = bracketsToConsider.filter(
|
||||
(bracket, bracketIdx) => {
|
||||
(bracketIdx) => {
|
||||
if (bracketIdx === 0) return true;
|
||||
|
||||
// underground brackets don't make their source bracket an intermediate one
|
||||
const undergrounds = new Set(
|
||||
undergroundBracketIdxs(bracketIdx, progression),
|
||||
);
|
||||
|
||||
return progression.every(
|
||||
(b) => !b.sources?.some((s) => s.bracketIdx === bracket),
|
||||
(b, idx) =>
|
||||
undergrounds.has(idx) ||
|
||||
!b.sources?.some((s) => s.bracketIdx === bracketIdx),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1033,6 +1040,23 @@ export function destinationsFromBracketIdx(
|
||||
return destinations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the indexes of the underground brackets sourced from the given bracket.
|
||||
* An underground bracket is one that takes teams eliminated from its source bracket (negative placements).
|
||||
*/
|
||||
export function undergroundBracketIdxs(
|
||||
bracketIdx: number,
|
||||
progression: ParsedBracket[],
|
||||
): number[] {
|
||||
return destinationsFromBracketIdx(bracketIdx, progression).filter((idx) =>
|
||||
progression[idx].sources?.some(
|
||||
(source) =>
|
||||
source.bracketIdx === bracketIdx &&
|
||||
source.placements.some((placement) => placement < 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function destinationByPlacement({
|
||||
sourceBracketIdx,
|
||||
placement,
|
||||
|
||||
@@ -1049,6 +1049,7 @@ export class Tournament {
|
||||
type: "MATCH",
|
||||
matchId: match.id,
|
||||
opponent: otherTeam.name,
|
||||
opponentId: otherTeam.id,
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -336,4 +336,46 @@ export const progressions = {
|
||||
],
|
||||
},
|
||||
],
|
||||
swissToTwoSingleEliminationsWithUnderground: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "swiss",
|
||||
settings: {
|
||||
groupCount: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Alpha",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Beta",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [9, 10, 11, 12, 13, 14, 15, 16],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Alpha UG",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 1,
|
||||
placements: [-1],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Progression.ParsedBracket[]>;
|
||||
|
||||
@@ -7,11 +7,8 @@ import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDBCached,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
parseParams,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { errorToastIfFalsy, parseParams } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import * as TournamentLFGRepository from "../TournamentLFGRepository.server";
|
||||
@@ -22,11 +19,17 @@ import { setPickupChatMetadata } from "../tournament-lfg-utils.server";
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const { id: tournamentId } = parseParams({ params, schema: idObject });
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: lookingSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const findOwnGroup = async () => {
|
||||
const groups =
|
||||
await TournamentLFGRepository.findLookingTeamsByTournamentId(
|
||||
|
||||
@@ -211,17 +211,19 @@ export function TournamentMatchBanner({
|
||||
/>
|
||||
</MatchBanner>
|
||||
) : null}
|
||||
<MatchBannerBottomRow
|
||||
games={resolveBannerGames({ data })}
|
||||
activeRosters={
|
||||
opponentOne?.id && opponentTwo?.id
|
||||
? {
|
||||
alpha: activeRosterByTeamId(opponentOne.id),
|
||||
bravo: activeRosterByTeamId(opponentTwo.id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
{data.matchIsOver ? null : (
|
||||
<MatchBannerBottomRow
|
||||
games={resolveBannerGames({ data })}
|
||||
activeRosters={
|
||||
opponentOne?.id && opponentTwo?.id
|
||||
? {
|
||||
alpha: activeRosterByTeamId(opponentOne.id),
|
||||
bravo: activeRosterByTeamId(opponentTwo.id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</MatchBannerContainer>
|
||||
);
|
||||
}
|
||||
@@ -245,6 +247,12 @@ function TournamentMatchBannerTopRow({
|
||||
const startedAt = databaseTimestampToDate(data.match.startedAt);
|
||||
const totalMinutes = differenceInMinutes(currentTime, startedAt);
|
||||
|
||||
const lastResultCreatedAt = data.results.at(-1)?.createdAt;
|
||||
const endedAt =
|
||||
typeof lastResultCreatedAt === "number"
|
||||
? databaseTimestampToDate(lastResultCreatedAt)
|
||||
: null;
|
||||
|
||||
const currentMinutes = resolveCurrentMinutes({
|
||||
data,
|
||||
tournament,
|
||||
@@ -262,7 +270,7 @@ function TournamentMatchBannerTopRow({
|
||||
}}
|
||||
>
|
||||
{data.matchIsOver ? (
|
||||
<MatchBannerStartedAt time={startedAt} />
|
||||
<MatchBannerStartedAt time={startedAt} endTime={endedAt} />
|
||||
) : (
|
||||
<MatchBannerTimer time={{ currentMinutes, totalMinutes }} />
|
||||
)}
|
||||
@@ -552,8 +560,6 @@ function resolveBannerGames({
|
||||
mode: map.mode as ModeShort | null,
|
||||
})) ?? [];
|
||||
|
||||
if (data.matchIsOver) return playedAndScheduled;
|
||||
|
||||
const placeholderCount = Math.max(
|
||||
0,
|
||||
data.match.roundMaps.count - playedAndScheduled.length,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tou
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { getServerTFunction } from "~/modules/i18n/i18next.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { actionError } from "~/utils/remix.server";
|
||||
import { tournamentOrganizationPage } from "~/utils/urls";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
import { organizationEditFormSchema } from "../tournament-organization-schemas";
|
||||
@@ -35,10 +34,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
)
|
||||
) {
|
||||
return actionError<typeof organizationEditFormSchema>({
|
||||
msg: t("org:edit.form.errors.noUnadmin"),
|
||||
field: "members.root",
|
||||
});
|
||||
return {
|
||||
fieldErrors: { members: t("org:edit.form.errors.noUnadmin") },
|
||||
};
|
||||
}
|
||||
|
||||
const socials = data.socials.filter((s) => s.length > 0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isFuture } from "date-fns";
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import {
|
||||
requirePermission,
|
||||
requireRole,
|
||||
@@ -10,11 +11,7 @@ import {
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToast,
|
||||
errorToastIfFalsy,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
|
||||
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
|
||||
@@ -24,11 +21,17 @@ import { organizationFromParams } from "../tournament-organization-utils.server"
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const organization = await organizationFromParams(params);
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: orgPageActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
switch (data._action) {
|
||||
case "BAN_USER": {
|
||||
requirePermission(organization, "BAN");
|
||||
|
||||
@@ -285,7 +285,7 @@ function AdminControls() {
|
||||
defaultValues={{
|
||||
isEstablished: Boolean(data.organization.isEstablished),
|
||||
}}
|
||||
autoSubmit
|
||||
mode="autoSubmit"
|
||||
>
|
||||
{({ FormField }) => <FormField name="isEstablished" />}
|
||||
</SendouForm>
|
||||
|
||||
@@ -12,15 +12,15 @@ import {
|
||||
select,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
toggle,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
|
||||
const orgNameField = textFieldRequired({
|
||||
const orgNameField = textField({
|
||||
label: "labels.name",
|
||||
minLength: 2,
|
||||
maxLength: 64,
|
||||
@@ -65,14 +65,14 @@ export const organizationEditFormSchema = z.object({
|
||||
socials: array({
|
||||
label: "labels.orgSocialLinks",
|
||||
max: 10,
|
||||
field: textFieldRequired({ validate: "url", maxLength: 100 }),
|
||||
field: textField({ validate: "url", maxLength: 100 }),
|
||||
}),
|
||||
series: array({
|
||||
label: "labels.orgSeries",
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({
|
||||
name: textField({
|
||||
label: "labels.orgSeriesName",
|
||||
minLength: 1,
|
||||
maxLength: 32,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";
|
||||
import { Bookmark, BookmarkCheck } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { ShareUrlButton } from "~/components/ShareUrlButton";
|
||||
import TimePopover from "~/components/TimePopover";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
@@ -93,7 +93,9 @@ export function TournamentHeaderActions({
|
||||
aria-label="Discord"
|
||||
/>
|
||||
) : null}
|
||||
<ShareTournamentButton tournament={tournament} />
|
||||
<ShareUrlButton
|
||||
url={`${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,43 +169,3 @@ function OrganizerLink({ tournament }: { tournament: Tournament }) {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareTournamentButton({ tournament }: { tournament: Tournament }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`;
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.share({ url });
|
||||
};
|
||||
|
||||
if (
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.share === "function"
|
||||
) {
|
||||
return (
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<Share2 />}
|
||||
onPress={handleShare}
|
||||
aria-label={t("common:actions.share")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyToClipboardPopover
|
||||
url={url}
|
||||
trigger={
|
||||
<SendouButton
|
||||
variant="outlined"
|
||||
size="small"
|
||||
shape="circle"
|
||||
icon={<Share2 />}
|
||||
aria-label={t("common:actions.share")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,77 @@ describe("tournamentStandings", () => {
|
||||
expect(a.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
expect(b.standings.map((s) => s.placement)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("breaks ties of a bracket with the results of its underground bracket", () => {
|
||||
const tournament = singleEliminationWithUndergroundTournament();
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
// teams 5-8 all lost the quarterfinals so they are tied in the main bracket,
|
||||
// the underground bracket (won by 8, then 7, 6, 5) decides their order
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 7, 6, 5,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps teams that skipped the underground bracket tied below those who played it", () => {
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundSeeding: [7, 8],
|
||||
});
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 7, 5, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 7,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not break ties with an underground bracket that is still in progress", () => {
|
||||
// only the semi-finals of the underground bracket have been played so the two teams
|
||||
// still alive there have no placement yet
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundConsolationFinal: false,
|
||||
undergroundMatchesPlayed: 2,
|
||||
});
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
// teams 5-8 keep the order & tied placement they have in the main bracket,
|
||||
// the teams eliminated from the underground bracket are not sorted above those still in it
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 5, 7, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 5, 5, 5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not break ties with an underground bracket that was never started", () => {
|
||||
// an underground bracket set in the progression can be skipped altogether
|
||||
const tournament = singleEliminationWithUndergroundTournament({
|
||||
undergroundStarted: false,
|
||||
});
|
||||
expect(tournament.bracketByIdx(1)?.preview).toBe(true);
|
||||
|
||||
const result = tournamentStandings(tournament);
|
||||
|
||||
invariant(result.type === "single");
|
||||
expect(result.standings.map((s) => s.team.id)).toEqual([
|
||||
1, 2, 3, 4, 8, 5, 7, 6,
|
||||
]);
|
||||
expect(result.standings.map((s) => s.placement)).toEqual([
|
||||
1, 2, 3, 4, 5, 5, 5, 5,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reNumberPlacements", () => {
|
||||
@@ -216,6 +287,68 @@ function singleEliminationTournament() {
|
||||
});
|
||||
}
|
||||
|
||||
function singleEliminationWithUndergroundTournament({
|
||||
undergroundSeeding = [5, 6, 7, 8],
|
||||
undergroundConsolationFinal = undergroundSeeding.length > 2,
|
||||
undergroundMatchesPlayed,
|
||||
undergroundStarted = true,
|
||||
}: {
|
||||
undergroundSeeding?: number[];
|
||||
undergroundConsolationFinal?: boolean;
|
||||
undergroundMatchesPlayed?: number;
|
||||
undergroundStarted?: boolean;
|
||||
} = {}) {
|
||||
const mainBracket = playOut(
|
||||
createResolved({
|
||||
type: "single_elimination",
|
||||
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
settings: { consolationFinal: true },
|
||||
}),
|
||||
(one, two) => one < two,
|
||||
);
|
||||
|
||||
const data = undergroundStarted
|
||||
? mergeStages(
|
||||
mainBracket,
|
||||
playOut(
|
||||
createResolved({
|
||||
type: "single_elimination",
|
||||
seeding: undergroundSeeding,
|
||||
settings: { consolationFinal: undergroundConsolationFinal },
|
||||
}),
|
||||
(one, two) => one > two,
|
||||
undergroundMatchesPlayed,
|
||||
),
|
||||
)
|
||||
: mainBracket;
|
||||
|
||||
return testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Main Bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: true },
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Underground",
|
||||
requiresCheckIn: false,
|
||||
settings: { thirdPlaceMatch: true },
|
||||
sources: [{ bracketIdx: 0, placements: [-1] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) =>
|
||||
tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }),
|
||||
),
|
||||
},
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
function abDivisionsTournament() {
|
||||
let data = createResolved({
|
||||
type: "round_robin",
|
||||
@@ -273,9 +406,22 @@ function abDivisionsTournament() {
|
||||
|
||||
/** Plays every match of the bracket data, the lower team id always winning. */
|
||||
function playOutLowerIdWins(data: BracketData) {
|
||||
let played = data;
|
||||
return playOut(data, (one, two) => one < two);
|
||||
}
|
||||
|
||||
while (true) {
|
||||
/**
|
||||
* Plays every match of the bracket data, `opponent1Wins` deciding each match by team id.
|
||||
* `maxMatches` can be given to leave the bracket in progress.
|
||||
*/
|
||||
function playOut(
|
||||
data: BracketData,
|
||||
opponent1Wins: (opponent1Id: number, opponent2Id: number) => boolean,
|
||||
maxMatches = Number.POSITIVE_INFINITY,
|
||||
) {
|
||||
let played = data;
|
||||
let playedCount = 0;
|
||||
|
||||
while (playedCount < maxMatches) {
|
||||
const pending = played.match.find(
|
||||
(match) =>
|
||||
typeof match.opponent1?.id === "number" &&
|
||||
@@ -284,12 +430,16 @@ function playOutLowerIdWins(data: BracketData) {
|
||||
);
|
||||
if (!pending) break;
|
||||
|
||||
const winnerIsOpp1 = pending.opponent1!.id! < pending.opponent2!.id!;
|
||||
const winnerIsOpp1 = opponent1Wins(
|
||||
pending.opponent1!.id as number,
|
||||
pending.opponent2!.id as number,
|
||||
);
|
||||
played = Engine.reportResult(played, {
|
||||
matchId: pending.id,
|
||||
scores: [winnerIsOpp1 ? 2 : 0, winnerIsOpp1 ? 0 : 2],
|
||||
winnerSide: winnerIsOpp1 ? "opponent1" : "opponent2",
|
||||
}).data;
|
||||
playedCount++;
|
||||
}
|
||||
|
||||
return played;
|
||||
|
||||
@@ -257,7 +257,11 @@ function tournamentStandingsForBracket(
|
||||
|
||||
const standings = standingsToMergeable({
|
||||
alreadyIncludedTeamIds,
|
||||
standings: bracket.standings,
|
||||
standings: tiebrokenByUndergroundBrackets({
|
||||
tournament,
|
||||
bracketIdx: idx,
|
||||
standings: bracket.standings,
|
||||
}),
|
||||
teamsAboveFromAnotherBracketsCount: alreadyIncludedTeamIds.size,
|
||||
});
|
||||
result.push(...standings);
|
||||
@@ -273,6 +277,85 @@ function tournamentStandingsForBracket(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Underground brackets are left out of the standings but the teams playing them are tied in their source
|
||||
* bracket (e.g. everyone who lost the quarterfinals shares the same placement), so their underground run
|
||||
* decides the order within each such tie. Teams that skipped the underground bracket stay tied last.
|
||||
*
|
||||
* An underground bracket that is still in progress is ignored, as the teams still alive in it have no
|
||||
* placement yet and would sort below the teams it already eliminated.
|
||||
*/
|
||||
function tiebrokenByUndergroundBrackets({
|
||||
tournament,
|
||||
bracketIdx,
|
||||
standings,
|
||||
}: {
|
||||
tournament: Tournament;
|
||||
bracketIdx: number;
|
||||
standings: Standing[];
|
||||
}): Standing[] {
|
||||
const undergroundPlacements = new Map<number, number>();
|
||||
|
||||
for (const undergroundIdx of Progression.undergroundBracketIdxs(
|
||||
bracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
)) {
|
||||
const underground = tournament.bracketByIdx(undergroundIdx);
|
||||
if (!underground?.everyMatchOver) continue;
|
||||
|
||||
for (const standing of underground.standings) {
|
||||
if (undergroundPlacements.has(standing.team.id)) continue;
|
||||
|
||||
undergroundPlacements.set(standing.team.id, standing.placement);
|
||||
}
|
||||
}
|
||||
|
||||
if (undergroundPlacements.size === 0) return standings;
|
||||
|
||||
const result: Standing[] = [];
|
||||
|
||||
for (const tied of groupedByPlacement(standings)) {
|
||||
const sorted = R.sortBy(
|
||||
tied,
|
||||
(standing) =>
|
||||
undergroundPlacements.get(standing.team.id) ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
let placement = tied[0].placement;
|
||||
let previousUndergroundPlacement: number | null = null;
|
||||
|
||||
for (const [index, standing] of sorted.entries()) {
|
||||
const undergroundPlacement =
|
||||
undergroundPlacements.get(standing.team.id) ?? null;
|
||||
|
||||
if (index > 0 && undergroundPlacement !== previousUndergroundPlacement) {
|
||||
placement = tied[0].placement + index;
|
||||
}
|
||||
previousUndergroundPlacement = undergroundPlacement;
|
||||
|
||||
result.push({ ...standing, placement });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupedByPlacement(standings: Standing[]): Standing[][] {
|
||||
const result: Standing[][] = [];
|
||||
|
||||
for (const standing of standings) {
|
||||
const previous = result.at(-1);
|
||||
|
||||
if (previous && previous[0].placement === standing.placement) {
|
||||
previous.push(standing);
|
||||
} else {
|
||||
result.push([standing]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function standingsToMergeable<
|
||||
T extends { team: { id: number }; placement: number },
|
||||
>({
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { preferenceEmojiUrl, userCardNotePage } from "~/utils/urls";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { userCardNotePage } from "~/utils/urls";
|
||||
import { userCardNoteSaveSchema } from "../user-card-schemas";
|
||||
|
||||
type PrivateNote = Pick<Tables["PrivateUserNote"], "text" | "sentiment">;
|
||||
|
||||
/**
|
||||
* Modal for adding/editing the viewer's private note about a user, posting to the
|
||||
* `/user-card/:id/note` resource route. Closes once the fetcher settles (save succeeds →
|
||||
* automatic revalidation refreshes the card). Clearing the text with a neutral sentiment and saving
|
||||
* deletes the note (handled by the route). Rendered wherever a `UserCard` lives.
|
||||
* `/user-card/:id/note` resource route. Closes once the save succeeds (automatic revalidation
|
||||
* refreshes the card). Clearing the text with a neutral sentiment and saving deletes the note
|
||||
* (handled by the route). Rendered wherever a `UserCard` lives.
|
||||
*/
|
||||
export function AddPrivateNoteDialog({
|
||||
userId,
|
||||
@@ -29,119 +25,29 @@ export function AddPrivateNoteDialog({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const wasSubmittingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state !== "idle") {
|
||||
wasSubmittingRef.current = true;
|
||||
} else if (wasSubmittingRef.current) {
|
||||
wasSubmittingRef.current = false;
|
||||
if ((fetcher.data as { ok?: boolean } | undefined)?.ok) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, onClose]);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={t("q:privateNote.header", { name: username })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
<SendouForm
|
||||
schema={userCardNoteSaveSchema}
|
||||
action={userCardNotePage(userId)}
|
||||
className="stack md"
|
||||
defaultValues={{
|
||||
comment: note?.text ?? "",
|
||||
sentiment: note?.sentiment ?? "NEUTRAL",
|
||||
}}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
onSuccess={onClose}
|
||||
>
|
||||
<Textarea initialValue={note?.text} />
|
||||
<Sentiment initialValue={note?.sentiment} />
|
||||
<div className="stack items-center mt-2">
|
||||
<SubmitButton _action="SAVE" state={fetcher.state}>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="comment" />
|
||||
<FormField name="sentiment" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Sentiment({
|
||||
initialValue,
|
||||
}: {
|
||||
initialValue?: Tables["PrivateUserNote"]["sentiment"];
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [sentiment, setSentiment] = React.useState<
|
||||
Tables["PrivateUserNote"]["sentiment"]
|
||||
>(initialValue ?? "NEUTRAL");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{t("q:privateNote.sentiment.header")}</Label>
|
||||
<input type="hidden" name="sentiment" value={sentiment} />
|
||||
<div className="stack xs my-2">
|
||||
{(["POSITIVE", "NEUTRAL", "NEGATIVE"] as const).map(
|
||||
(sentimentRadio) => {
|
||||
return (
|
||||
<div
|
||||
key={sentimentRadio}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={sentimentRadio}
|
||||
checked={sentimentRadio === sentiment}
|
||||
onChange={() => setSentiment(sentimentRadio)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={sentimentRadio}
|
||||
className="mb-0 stack horizontal xs"
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl(
|
||||
sentimentRadio === "POSITIVE"
|
||||
? "PREFER"
|
||||
: sentimentRadio === "NEGATIVE"
|
||||
? "AVOID"
|
||||
: undefined,
|
||||
)}
|
||||
alt=""
|
||||
width={18}
|
||||
/>
|
||||
{t(`q:privateNote.sentiment.${sentimentRadio}`)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<FormMessage type="info">{t("q:privateNote.sentiment.info")}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({ initialValue }: { initialValue?: string | null }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [value, setValue] = React.useState(initialValue ?? "");
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<Label
|
||||
htmlFor="comment"
|
||||
valueLimits={{
|
||||
current: value.length,
|
||||
max: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
|
||||
}}
|
||||
>
|
||||
{t("q:privateNote.comment.header")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="comment"
|
||||
name="comment"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { parseParams, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import {
|
||||
userCardNoteParamsSchema,
|
||||
userCardNoteSchema,
|
||||
@@ -14,11 +15,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
params,
|
||||
schema: userCardNoteParamsSchema,
|
||||
}).id;
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: userCardNoteSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const isEmptySave =
|
||||
data._action === "SAVE" &&
|
||||
data.comment === null &&
|
||||
@@ -26,7 +33,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
if (data._action === "DELETE" || isEmptySave) {
|
||||
await PrivateUserNoteRepository.deleteOwnNoteById(targetId);
|
||||
return { ok: true };
|
||||
return null;
|
||||
}
|
||||
|
||||
await PrivateUserNoteRepository.upsertOwnNote({
|
||||
@@ -35,5 +42,5 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
customField,
|
||||
image,
|
||||
numberFieldOptional,
|
||||
radioGroup,
|
||||
select,
|
||||
stageSelect,
|
||||
stringConstant,
|
||||
textAreaOptional,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { _action, falsyToNull, id } from "~/utils/zod";
|
||||
import { preferenceEmojiUrl } from "~/utils/urls";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
|
||||
import { USER_CARD } from "./user-card-constants";
|
||||
|
||||
@@ -51,15 +54,37 @@ export const updateUserCardSchema = z.object({
|
||||
hideDiv: toggle({ label: "labels.hideDiv" }),
|
||||
});
|
||||
|
||||
export const userCardNoteSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("SAVE"),
|
||||
comment: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
|
||||
export const userCardNoteSaveSchema = z.object({
|
||||
_action: stringConstant("SAVE"),
|
||||
comment: textAreaOptional({
|
||||
label: "labels.comment",
|
||||
maxLength: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
|
||||
}),
|
||||
sentiment: radioGroup({
|
||||
label: "labels.sentiment",
|
||||
bottomText: "bottomTexts.sentiment",
|
||||
items: [
|
||||
{
|
||||
value: "POSITIVE",
|
||||
label: "options.sentiment.POSITIVE",
|
||||
imgSrc: preferenceEmojiUrl("PREFER"),
|
||||
},
|
||||
{
|
||||
value: "NEUTRAL",
|
||||
label: "options.sentiment.NEUTRAL",
|
||||
imgSrc: preferenceEmojiUrl(),
|
||||
},
|
||||
{
|
||||
value: "NEGATIVE",
|
||||
label: "options.sentiment.NEGATIVE",
|
||||
imgSrc: preferenceEmojiUrl("AVOID"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export const userCardNoteSchema = z.union([
|
||||
userCardNoteSaveSchema,
|
||||
z.object({
|
||||
_action: _action("DELETE"),
|
||||
}),
|
||||
|
||||
@@ -3,12 +3,9 @@ import * as AdminRepository from "~/features/admin/AdminRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { adminTabActionSchema } from "~/features/user-page/user-page-schemas";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
notFoundIfNullish,
|
||||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { badRequestIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
@@ -16,11 +13,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
requireRole("STAFF");
|
||||
|
||||
const data = await parseRequestPayload({
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
schema: adminTabActionSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
const user = notFoundIfNullish(
|
||||
await UserRepository.findLayoutDataByIdentifier(params.identifier!),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Tables } from "~/db/tables";
|
||||
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import {
|
||||
CONTROLLERS,
|
||||
getWidgetFormSchema,
|
||||
TIMEZONE_OPTIONS,
|
||||
} from "../core/widgets/widget-form-schemas";
|
||||
@@ -55,7 +54,7 @@ function WidgetSettingsFormInner({
|
||||
<SendouForm
|
||||
schema={schema}
|
||||
defaultValues={defaultValues}
|
||||
autoApply
|
||||
mode="client"
|
||||
onApply={handleApply}
|
||||
className="stack md"
|
||||
>
|
||||
@@ -91,13 +90,7 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
|
||||
case "links":
|
||||
return <FormField name="links" />;
|
||||
case "tier-list":
|
||||
return (
|
||||
<FormField name="searchParams">
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<TierListField {...(props as CustomFieldRenderProps<string>)} />
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
return <FormField name="searchParams" />;
|
||||
case "game-badges":
|
||||
return (
|
||||
<FormField name="badgeIds">
|
||||
@@ -134,21 +127,12 @@ function SensFields() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const { values, setValue, onFieldChange } = useFormFieldContext();
|
||||
|
||||
const controller =
|
||||
(values.controller as (typeof CONTROLLERS)[number]) ?? "s2-pro-con";
|
||||
const motionSens = (values.motionSens as number | null) ?? null;
|
||||
const stickSens = (values.stickSens as number | null) ?? null;
|
||||
|
||||
const rawSensToString = (sens: number) =>
|
||||
`${sens > 0 ? "+" : ""}${sens / 10}`;
|
||||
|
||||
const handleControllerChange = (
|
||||
newController: (typeof CONTROLLERS)[number],
|
||||
) => {
|
||||
setValue("controller", newController);
|
||||
onFieldChange?.("controller", newController);
|
||||
};
|
||||
|
||||
const handleMotionSensChange = (sens: number | null) => {
|
||||
setValue("motionSens", sens);
|
||||
onFieldChange?.("motionSens", sens);
|
||||
@@ -161,25 +145,7 @@ function SensFields() {
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<label htmlFor="controller">{t("widgets.forms.controller")}</label>
|
||||
<select
|
||||
id="controller"
|
||||
value={controller}
|
||||
onChange={(e) =>
|
||||
handleControllerChange(
|
||||
e.target.value as (typeof CONTROLLERS)[number],
|
||||
)
|
||||
}
|
||||
className={clsx(styles.sensSelect)}
|
||||
>
|
||||
{CONTROLLERS.map((ctrl) => (
|
||||
<option key={ctrl} value={ctrl}>
|
||||
{t(`user:controllers.${ctrl}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<FormField name="controller" />
|
||||
|
||||
<div className="stack horizontal md">
|
||||
<div>
|
||||
@@ -227,41 +193,3 @@ function SensFields() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TierListField({ value, onChange }: CustomFieldRenderProps<string>) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (inputValue.includes("/tier-list-maker")) {
|
||||
try {
|
||||
const url = new URL(inputValue, "https://sendou.ink");
|
||||
const extractedSearchParams = url.search.substring(1);
|
||||
onChange(extractedSearchParams);
|
||||
return;
|
||||
} catch {
|
||||
// not a valid URL, just use the value as-is
|
||||
}
|
||||
}
|
||||
|
||||
onChange(inputValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="tier-list-searchParams">
|
||||
{t("widgets.forms.tierListUrl")}
|
||||
</label>
|
||||
<div className="input-container">
|
||||
<div className="input-addon">/tier-list-maker?</div>
|
||||
<input
|
||||
className="in-container"
|
||||
id="tier-list-searchParams"
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
select,
|
||||
selectDynamic,
|
||||
stageSelect,
|
||||
textAreaRequired,
|
||||
textFieldRequired,
|
||||
textArea,
|
||||
textField,
|
||||
weaponSelect,
|
||||
} from "~/form/fields";
|
||||
import type { SelectOption } from "~/form/types";
|
||||
@@ -17,34 +17,34 @@ import { GAME_BADGE_IDS } from "~/modules/in-game-lists/game-badge-ids";
|
||||
import { USER } from "../../user-page-constants";
|
||||
|
||||
export const bioSchema = z.object({
|
||||
bio: textAreaRequired({
|
||||
bio: textArea({
|
||||
label: "labels.bio",
|
||||
maxLength: USER.BIO_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const bioMdSchema = z.object({
|
||||
bio: textAreaRequired({
|
||||
bio: textArea({
|
||||
label: "labels.bio",
|
||||
bottomText: "bottomTexts.bioMarkdown" as never,
|
||||
bottomText: "bottomTexts.bioMarkdown",
|
||||
maxLength: USER.BIO_MD_MAX_LENGTH,
|
||||
}),
|
||||
});
|
||||
|
||||
export const xRankPeaksSchema = z.object({
|
||||
division: select({
|
||||
label: "labels.division" as never,
|
||||
label: "labels.division",
|
||||
items: [
|
||||
{ value: "both", label: "options.division.both" as never },
|
||||
{ value: "tentatek", label: "options.division.tentatek" as never },
|
||||
{ value: "takoroka", label: "options.division.takoroka" as never },
|
||||
{ value: "both", label: "options.division.both" },
|
||||
{ value: "tentatek", label: "options.division.tentatek" },
|
||||
{ value: "takoroka", label: "options.division.takoroka" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export const timezoneSchema = z.object({
|
||||
timezone: selectDynamic({
|
||||
label: "labels.timezone" as never,
|
||||
label: "labels.timezone",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -55,50 +55,52 @@ export const TIMEZONE_OPTIONS: SelectOption[] = TIMEZONES.map((tz) => ({
|
||||
|
||||
export const favoriteStageSchema = z.object({
|
||||
stageId: stageSelect({
|
||||
label: "labels.favoriteStage" as never,
|
||||
label: "labels.favoriteStage",
|
||||
}),
|
||||
});
|
||||
|
||||
export const peakXpUnverifiedSchema = z.object({
|
||||
peakXp: numberField({
|
||||
label: "labels.peakXp" as never,
|
||||
label: "labels.peakXp",
|
||||
minLength: 4,
|
||||
maxLength: 4,
|
||||
}),
|
||||
division: select({
|
||||
label: "labels.division" as never,
|
||||
label: "labels.division",
|
||||
items: [
|
||||
{ value: "tentatek", label: "options.division.tentatek" as never },
|
||||
{ value: "takoroka", label: "options.division.takoroka" as never },
|
||||
{ value: "tentatek", label: "options.division.tentatek" },
|
||||
{ value: "takoroka", label: "options.division.takoroka" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export const peakXpWeaponSchema = z.object({
|
||||
weaponSplId: weaponSelect({
|
||||
label: "labels.weapon" as never,
|
||||
label: "labels.weapon",
|
||||
}),
|
||||
});
|
||||
|
||||
export const CONTROLLERS = [
|
||||
"s1-pro-con",
|
||||
"s2-pro-con",
|
||||
"grip",
|
||||
"handheld",
|
||||
] as const;
|
||||
const CONTROLLERS = ["s1-pro-con", "s2-pro-con", "grip", "handheld"] as const;
|
||||
|
||||
export const sensSchema = z.object({
|
||||
controller: customField({ initialValue: "s2-pro-con" }, z.enum(CONTROLLERS)),
|
||||
controller: select({
|
||||
label: "labels.controller",
|
||||
items: CONTROLLERS.map((controller) => ({
|
||||
value: controller,
|
||||
label: `options.controller.${controller}` as const,
|
||||
})),
|
||||
initialValue: "s2-pro-con",
|
||||
}),
|
||||
motionSens: customField({ initialValue: null }, z.number().nullable()),
|
||||
stickSens: customField({ initialValue: null }, z.number().nullable()),
|
||||
});
|
||||
|
||||
export const artSchema = z.object({
|
||||
source: select({
|
||||
label: "labels.artSource" as never,
|
||||
label: "labels.artSource",
|
||||
items: ART_SOURCES.map((source) => ({
|
||||
value: source,
|
||||
label: `options.artSource.${source}` as never,
|
||||
label: `options.artSource.${source}`,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
@@ -108,7 +110,7 @@ export const linksSchema = z.object({
|
||||
label: "labels.urls",
|
||||
min: 1,
|
||||
max: 10,
|
||||
field: textFieldRequired({
|
||||
field: textField({
|
||||
maxLength: 150,
|
||||
validate: "url",
|
||||
}),
|
||||
@@ -116,10 +118,11 @@ export const linksSchema = z.object({
|
||||
});
|
||||
|
||||
export const tierListSchema = z.object({
|
||||
searchParams: textFieldRequired({
|
||||
label: "labels.tierListUrl" as never,
|
||||
searchParams: textField({
|
||||
label: "labels.tierListUrl",
|
||||
leftAddon: "/tier-list-maker?",
|
||||
maxLength: 500,
|
||||
transformValue: pastedTierListUrlToSearchParams,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -160,3 +163,14 @@ const WIDGET_FORM_SCHEMAS: Record<string, z.ZodObject<z.ZodRawShape>> = {
|
||||
export function getWidgetFormSchema(widgetId: string) {
|
||||
return WIDGET_FORM_SCHEMAS[widgetId];
|
||||
}
|
||||
|
||||
/** Lets the user paste a whole tier list maker URL instead of only its query string. */
|
||||
function pastedTierListUrlToSearchParams(value: string) {
|
||||
if (!value.includes("/tier-list-maker")) return value;
|
||||
|
||||
try {
|
||||
return new URL(value, "https://sendou.ink").search.substring(1);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
inGameName,
|
||||
selectDynamicOptional,
|
||||
stringConstant,
|
||||
textArea,
|
||||
textAreaOptional,
|
||||
textAreaRequired,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
toggle,
|
||||
weaponPool,
|
||||
} from "~/form/fields";
|
||||
@@ -180,7 +180,7 @@ export const editHighlightsActionSchema = z.object({
|
||||
|
||||
export const addModNoteSchema = z.object({
|
||||
_action: stringConstant("ADD_MOD_NOTE"),
|
||||
value: textAreaRequired({
|
||||
value: textArea({
|
||||
label: "labels.text",
|
||||
bottomText: "bottomTexts.modNote",
|
||||
maxLength: USER.MOD_NOTE_MAX_LENGTH,
|
||||
@@ -318,7 +318,7 @@ export const newBuildBaseSchema = z.object({
|
||||
},
|
||||
abilitiesSchema,
|
||||
),
|
||||
title: textFieldRequired({
|
||||
title: textField({
|
||||
label: "labels.buildTitle",
|
||||
maxLength: 50,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { select, textAreaRequired, textFieldOptional } from "~/form/fields";
|
||||
import { select, textArea, textFieldOptional } from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { USER_REPORT } from "./user-report-constants";
|
||||
|
||||
@@ -17,7 +17,7 @@ export const reportUserSchema = z.object({
|
||||
{ label: "options.userReportCategory.OTHER", value: "OTHER" },
|
||||
],
|
||||
}),
|
||||
description: textAreaRequired({
|
||||
description: textArea({
|
||||
label: "labels.description",
|
||||
maxLength: USER_REPORT.DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
|
||||
@@ -80,15 +80,11 @@ function renderForm(options?: {
|
||||
schema={vodFormBaseSchema}
|
||||
defaultValues={createDefaultValues(options?.defaultValues)}
|
||||
>
|
||||
{({ names }) => (
|
||||
<>
|
||||
{Object.keys(names)
|
||||
.filter((name) => name !== "pov")
|
||||
.map((name) => (
|
||||
<FormField key={name} name={name} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{Object.keys(vodFormBaseSchema.shape)
|
||||
.filter((name) => name !== "pov")
|
||||
.map((name) => (
|
||||
<FormField key={name} name={name} />
|
||||
))}
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Main } from "~/components/Main";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import { YouTubeEmbed } from "~/components/YouTubeEmbed";
|
||||
import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form";
|
||||
import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper";
|
||||
import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField";
|
||||
import type { FormRenderProps } from "~/form/SendouForm";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
@@ -240,46 +239,21 @@ function VodFormFields({
|
||||
}
|
||||
|
||||
function TeamSizeField({ FormField }: { FormField: VodFormFieldComponent }) {
|
||||
const { values, setValue } = useFormFieldContext();
|
||||
const matches = values.matches as Array<Record<string, unknown>>;
|
||||
const { setValueFromPrev } = useFormFieldContext();
|
||||
|
||||
const handleTeamSizeChange = (newValue: string | null) => {
|
||||
setValue("teamSize", newValue);
|
||||
|
||||
if (matches && Array.isArray(matches)) {
|
||||
const clearedMatches = matches.map((match) => ({
|
||||
// The weapon count per match is tied to the team size, so any already picked
|
||||
// weapons would no longer fit the new size.
|
||||
const clearMatchWeapons = () => {
|
||||
setValueFromPrev("matches", (prev) =>
|
||||
((prev ?? []) as Array<Record<string, unknown>>).map((match) => ({
|
||||
...match,
|
||||
weaponsTeamOne: [],
|
||||
weaponsTeamTwo: [],
|
||||
}));
|
||||
setValue("matches", clearedMatches);
|
||||
}
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormField name="teamSize">
|
||||
{({ name, error, value }: CustomFieldRenderProps) => (
|
||||
<FormFieldWrapper
|
||||
id={name}
|
||||
name={name}
|
||||
label="forms:labels.vodTeamSize"
|
||||
error={error}
|
||||
>
|
||||
<select
|
||||
id={name}
|
||||
name={name}
|
||||
value={(value as string) ?? "4"}
|
||||
onChange={(e) => handleTeamSizeChange(e.target.value)}
|
||||
>
|
||||
<option value="1">1v1</option>
|
||||
<option value="2">2v2</option>
|
||||
<option value="3">3v3</option>
|
||||
<option value="4">4v4</option>
|
||||
</select>
|
||||
</FormFieldWrapper>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
return <FormField name="teamSize" onValueChange={clearMatchWeapons} />;
|
||||
}
|
||||
|
||||
function PovFormField({ FormField }: { FormField: VodFormFieldComponent }) {
|
||||
@@ -420,33 +394,19 @@ function MatchFieldsetContent({
|
||||
</div>
|
||||
|
||||
<div className="stack md mt-4">
|
||||
<FormField name={`${itemName}.startsAt`}>
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<FormFieldWrapper
|
||||
id={`matches-${index}-startsAt`}
|
||||
name={`${itemName}.startsAt`}
|
||||
label="forms:labels.vodStartTimestamp"
|
||||
error={props.error}
|
||||
<div>
|
||||
<FormField name={`${itemName}.startsAt`} />
|
||||
{currentTime ? (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
onPress={() => setItemField("startsAt", currentTime)}
|
||||
className="mt-2"
|
||||
>
|
||||
<input
|
||||
id={`matches-${index}-startsAt`}
|
||||
value={matchValues.startsAt}
|
||||
onChange={(e) => setItemField("startsAt", e.target.value)}
|
||||
placeholder="10:22"
|
||||
/>
|
||||
{currentTime ? (
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
onPress={() => setItemField("startsAt", currentTime)}
|
||||
className="mt-2"
|
||||
>
|
||||
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</FormFieldWrapper>
|
||||
)}
|
||||
</FormField>
|
||||
{t("vods:forms.action.setAsCurrent", { time: currentTime })}
|
||||
</SendouButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FormField name={`${itemName}.mode`} />
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import { z } from "zod";
|
||||
import {
|
||||
array,
|
||||
customField,
|
||||
dayMonthYearRequired,
|
||||
dayMonthYear as dayMonthYearField,
|
||||
fieldset,
|
||||
idConstantOptional,
|
||||
radioGroup,
|
||||
select,
|
||||
selectOptional,
|
||||
stageSelect,
|
||||
textFieldRequired,
|
||||
textField,
|
||||
weaponPool,
|
||||
weaponSelectOptional,
|
||||
} from "~/form/fields";
|
||||
@@ -116,8 +116,9 @@ const povSchema = z.union([
|
||||
]);
|
||||
|
||||
const matchFieldsetSchema = z.object({
|
||||
startsAt: textFieldRequired({
|
||||
startsAt: textField({
|
||||
label: "labels.vodStartTimestamp",
|
||||
placeholder: "placeholders.vodStartTimestamp",
|
||||
maxLength: 10,
|
||||
regExp: {
|
||||
pattern: HOURS_MINUTES_SECONDS_REGEX,
|
||||
@@ -151,7 +152,7 @@ const matchFieldsetSchema = z.object({
|
||||
|
||||
export const vodFormBaseSchema = z.object({
|
||||
vodToEditId: idConstantOptional(),
|
||||
youtubeUrl: textFieldRequired({
|
||||
youtubeUrl: textField({
|
||||
label: "labels.vodYoutubeUrl",
|
||||
maxLength: 200,
|
||||
validate: {
|
||||
@@ -159,11 +160,11 @@ export const vodFormBaseSchema = z.object({
|
||||
message: "Invalid YouTube URL",
|
||||
},
|
||||
}),
|
||||
title: textFieldRequired({
|
||||
title: textField({
|
||||
label: "labels.vodTitle",
|
||||
maxLength: 100,
|
||||
}),
|
||||
date: dayMonthYearRequired({
|
||||
date: dayMonthYearField({
|
||||
label: "labels.vodDate",
|
||||
max: () => add(new Date(), { days: 1 }),
|
||||
maxMessage: "errors.dateMustNotBeFuture",
|
||||
|
||||
@@ -55,6 +55,8 @@ interface FormFieldProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
/** Focuses the field on mount. Only `text-field` and `text-area` support it. */
|
||||
autoFocus?: boolean;
|
||||
maxCount?: number;
|
||||
field?: z.ZodType;
|
||||
children?:
|
||||
@@ -64,17 +66,27 @@ interface FormFieldProps {
|
||||
options?: unknown;
|
||||
/** For `array` fields: hide the remove button for items where this returns false. */
|
||||
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
|
||||
/**
|
||||
* Runs after the new value has been stored. For side effects on other fields;
|
||||
* to change what gets stored use the field schema's own options instead.
|
||||
*/
|
||||
onValueChange?: (newValue: unknown) => void;
|
||||
}
|
||||
|
||||
/** Field types that render `children`. Any other type would silently discard it. */
|
||||
const FIELD_TYPES_WITH_RENDER_PROP = ["custom", "array"];
|
||||
|
||||
export function FormField({
|
||||
name,
|
||||
label,
|
||||
disabled,
|
||||
autoFocus,
|
||||
maxCount,
|
||||
field,
|
||||
children,
|
||||
options,
|
||||
canRemoveItem,
|
||||
onValueChange,
|
||||
}: FormFieldProps) {
|
||||
const context = useOptionalFormFieldContext();
|
||||
const isDisabled = disabled ?? context?.readOnly ?? false;
|
||||
@@ -153,11 +165,24 @@ export function FormField({
|
||||
context.setClientError(name, validationError);
|
||||
};
|
||||
|
||||
// After the first submit, changes revalidate the whole form — except array
|
||||
// appends, which stay silent so a freshly added empty item doesn't error
|
||||
// immediately. Blur is the moment the user leaves such an item, so
|
||||
// revalidating here surfaces its error without waiting for the next submit.
|
||||
const handleBlur = (latestValue?: unknown) => {
|
||||
if (hasSubmitted) return;
|
||||
if (!context) return;
|
||||
if (hasSubmitted) {
|
||||
context.revalidateAll(context.store.values);
|
||||
return;
|
||||
}
|
||||
runValidation(latestValue ?? value);
|
||||
};
|
||||
|
||||
// Read through a ref so an inline `onValueChange` does not destabilize
|
||||
// `handleChange`, which fields rely on to skip re-rendering.
|
||||
const latestOnValueChange = React.useRef(onValueChange);
|
||||
latestOnValueChange.current = onValueChange;
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(newValue: unknown) => {
|
||||
if (!context) return;
|
||||
@@ -171,12 +196,22 @@ export function FormField({
|
||||
context.revalidateAll(context.store.values);
|
||||
}
|
||||
context.onFieldChange?.(name, newValue);
|
||||
latestOnValueChange.current?.(newValue);
|
||||
},
|
||||
[context, name],
|
||||
);
|
||||
|
||||
const displayedError = serverError ?? clientError;
|
||||
|
||||
if (
|
||||
typeof children === "function" &&
|
||||
!FIELD_TYPES_WITH_RENDER_PROP.includes(formField.type)
|
||||
) {
|
||||
throw new Error(
|
||||
`Field "${name}" is of type "${formField.type}" which renders itself, so its render function child would never run. Remove the child or change the field to customField().`,
|
||||
);
|
||||
}
|
||||
|
||||
const commonProps = { name, error: displayedError, onBlur: handleBlur };
|
||||
|
||||
if (formField.type === "text-field") {
|
||||
@@ -185,6 +220,7 @@ export function FormField({
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
autoFocus={autoFocus}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
/>
|
||||
@@ -221,6 +257,7 @@ export function FormField({
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
autoFocus={autoFocus}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
/>
|
||||
@@ -232,6 +269,7 @@ export function FormField({
|
||||
<SelectFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as string | null}
|
||||
onChange={handleChange as (v: string | null) => void}
|
||||
/>
|
||||
@@ -247,6 +285,7 @@ export function FormField({
|
||||
<SelectFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
items={selectOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
@@ -262,6 +301,7 @@ export function FormField({
|
||||
<DualSelectFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as [string | null, string | null]}
|
||||
onChange={handleChange as (v: [string | null, string | null]) => void}
|
||||
/>
|
||||
@@ -273,6 +313,7 @@ export function FormField({
|
||||
<RadioGroupFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
/>
|
||||
@@ -288,6 +329,7 @@ export function FormField({
|
||||
<RadioGroupFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
items={radioItems}
|
||||
value={value as string}
|
||||
onChange={handleChange as (v: string) => void}
|
||||
@@ -300,6 +342,7 @@ export function FormField({
|
||||
<CheckboxGroupFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as string[]}
|
||||
onChange={handleChange as (v: string[]) => void}
|
||||
/>
|
||||
@@ -311,6 +354,7 @@ export function FormField({
|
||||
<DatetimeFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
granularity={formField.type === "date" ? "day" : "minute"}
|
||||
value={value as Date | undefined}
|
||||
onChange={handleChange as (v: Date | undefined) => void}
|
||||
@@ -323,6 +367,7 @@ export function FormField({
|
||||
<TimeRangeFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as { start: string; end: string } | null}
|
||||
onChange={
|
||||
handleChange as (v: { start: string; end: string } | null) => void
|
||||
@@ -336,6 +381,7 @@ export function FormField({
|
||||
<WeaponPoolFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as WeaponPoolItem[]}
|
||||
onChange={handleChange as (v: WeaponPoolItem[]) => void}
|
||||
/>
|
||||
@@ -365,15 +411,13 @@ export function FormField({
|
||||
error: displayedError,
|
||||
value,
|
||||
onChange: handleChange,
|
||||
disabled: isDisabled,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
formField.type === "string-constant" ||
|
||||
formField.type === "id-constant"
|
||||
) {
|
||||
if (formField.type === "hidden") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -393,6 +437,7 @@ export function FormField({
|
||||
<ArrayFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as unknown[]}
|
||||
onChange={handleChange as (v: unknown[]) => void}
|
||||
isObjectArray={isObjectArray}
|
||||
@@ -433,7 +478,12 @@ export function FormField({
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField key={idx} name={itemName} field={formField.field} />
|
||||
<FormField
|
||||
key={idx}
|
||||
name={itemName}
|
||||
field={formField.field}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -441,7 +491,9 @@ export function FormField({
|
||||
}
|
||||
|
||||
if (formField.type === "fieldset") {
|
||||
return <FieldsetFormField {...commonProps} {...formField} />;
|
||||
return (
|
||||
<FieldsetFormField {...commonProps} {...formField} disabled={disabled} />
|
||||
);
|
||||
}
|
||||
|
||||
if (formField.type === "user-search") {
|
||||
@@ -450,6 +502,7 @@ export function FormField({
|
||||
<UserSearchFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as number | null}
|
||||
onChange={handleChange as (v: number | null) => void}
|
||||
onUserSelected={userOptions?.onUserSelected}
|
||||
@@ -465,6 +518,7 @@ export function FormField({
|
||||
<TournamentSearchFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as number | null}
|
||||
onChange={handleChange as (v: number | null) => void}
|
||||
pastOnly={tournamentOptions?.pastOnly}
|
||||
@@ -478,6 +532,7 @@ export function FormField({
|
||||
<TeamSearchFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
onChange={handleChange as (v: number | null) => void}
|
||||
onTeamSelected={teamOptions?.onTeamSelected}
|
||||
initialTeam={teamOptions?.initialTeam}
|
||||
@@ -493,6 +548,7 @@ export function FormField({
|
||||
<BadgesFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as number[]}
|
||||
onChange={handleChange as (v: number[]) => void}
|
||||
options={options as BadgeOption[]}
|
||||
@@ -506,6 +562,7 @@ export function FormField({
|
||||
<StageSelectFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as StageId | null}
|
||||
onChange={handleChange as (v: StageId) => void}
|
||||
/>
|
||||
@@ -517,6 +574,7 @@ export function FormField({
|
||||
<WeaponSelectFormField
|
||||
{...commonProps}
|
||||
{...formField}
|
||||
disabled={isDisabled}
|
||||
value={value as MainWeaponId | null}
|
||||
onChange={handleChange as (v: MainWeaponId | null) => void}
|
||||
/>
|
||||
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
radioGroup,
|
||||
select,
|
||||
selectOptional,
|
||||
textArea,
|
||||
textAreaOptional,
|
||||
textAreaRequired,
|
||||
textField,
|
||||
textFieldOptional,
|
||||
textFieldRequired,
|
||||
timeRangeOptional,
|
||||
toggle as toggleField,
|
||||
userSearch,
|
||||
@@ -46,7 +46,7 @@ function renderForm(
|
||||
defaultValues?: Record<string, unknown>;
|
||||
title?: string;
|
||||
submitButtonText?: string;
|
||||
autoSubmit?: boolean;
|
||||
mode?: "autoSubmit";
|
||||
},
|
||||
) {
|
||||
const props: ComponentProps<typeof SendouForm<z.ZodRawShape>> = {
|
||||
@@ -54,10 +54,10 @@ function renderForm(
|
||||
defaultValues: options?.defaultValues,
|
||||
title: options?.title,
|
||||
submitButtonText: options?.submitButtonText,
|
||||
autoSubmit: options?.autoSubmit,
|
||||
children: ({ names }) => (
|
||||
mode: options?.mode,
|
||||
children: (
|
||||
<>
|
||||
{Object.keys(names).map((name) => (
|
||||
{Object.keys(schema.shape).map((name) => (
|
||||
<FormField key={name} name={name} />
|
||||
))}
|
||||
</>
|
||||
@@ -85,7 +85,7 @@ describe("SendouForm", () => {
|
||||
describe("basic form rendering", () => {
|
||||
test("renders form with title", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema, { title: "Test Form" });
|
||||
@@ -95,7 +95,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("renders submit button with default text", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -107,7 +107,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("renders submit button with custom text", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema, {
|
||||
@@ -119,12 +119,12 @@ describe("SendouForm", () => {
|
||||
.toBeVisible();
|
||||
});
|
||||
|
||||
test("hides submit button when autoSubmit is true", async () => {
|
||||
test("hides submit button in autoSubmit mode", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema, { autoSubmit: true });
|
||||
const screen = await renderForm(schema, { mode: "autoSubmit" });
|
||||
|
||||
const submitButton = screen.container.querySelector(
|
||||
'button[type="submit"]',
|
||||
@@ -136,7 +136,7 @@ describe("SendouForm", () => {
|
||||
describe("text field", () => {
|
||||
test("renders with label", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -146,7 +146,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("typing updates value", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -159,7 +159,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("shows error on blur when required field is empty", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -175,7 +175,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("shows error on submit when required field is empty", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -189,7 +189,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("clears error when valid value is entered after submit", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -221,7 +221,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("initializes with default value", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema, {
|
||||
@@ -312,7 +312,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("required text area shows error when empty", async () => {
|
||||
const schema = z.object({
|
||||
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
|
||||
bio: textArea({ label: "labels.bio", maxLength: 500 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -599,8 +599,8 @@ describe("SendouForm", () => {
|
||||
describe("validation", () => {
|
||||
test("validates multiple fields on submit", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
bio: textArea({ label: "labels.bio", maxLength: 500 }),
|
||||
});
|
||||
|
||||
const screen = await renderForm(schema);
|
||||
@@ -618,7 +618,7 @@ describe("SendouForm", () => {
|
||||
describe("default values", () => {
|
||||
test("initializes multiple fields with default values", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
|
||||
});
|
||||
|
||||
@@ -659,7 +659,7 @@ describe("SendouForm", () => {
|
||||
describe("server error fallback", () => {
|
||||
test("shows fallback error when server returns error for field without DOM element", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
mockFetcherData = {
|
||||
@@ -677,7 +677,7 @@ describe("SendouForm", () => {
|
||||
|
||||
test("does not show fallback error when server error has corresponding DOM element", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
mockFetcherData = {
|
||||
@@ -741,7 +741,7 @@ describe("SendouForm", () => {
|
||||
test("calls onApply with form values instead of fetcher.submit", async () => {
|
||||
const onApply = vi.fn();
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const router = createMemoryRouter(
|
||||
@@ -754,7 +754,7 @@ describe("SendouForm", () => {
|
||||
defaultValues={{ name: "Test Value" }}
|
||||
onApply={onApply}
|
||||
>
|
||||
{({ names }) => <FormField name={names.name} />}
|
||||
<FormField name="name" />
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
@@ -771,7 +771,7 @@ describe("SendouForm", () => {
|
||||
test("does not call onApply when validation fails", async () => {
|
||||
const onApply = vi.fn();
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
});
|
||||
|
||||
const router = createMemoryRouter(
|
||||
@@ -780,7 +780,7 @@ describe("SendouForm", () => {
|
||||
path: "/",
|
||||
element: (
|
||||
<SendouForm schema={schema} onApply={onApply}>
|
||||
{({ names }) => <FormField name={names.name} />}
|
||||
<FormField name="name" />
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
@@ -804,7 +804,7 @@ describe("SendouForm", () => {
|
||||
member: fieldset({
|
||||
label: "labels.member",
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -819,7 +819,7 @@ describe("SendouForm", () => {
|
||||
member: fieldset({
|
||||
label: "labels.member",
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
|
||||
}),
|
||||
}),
|
||||
@@ -836,7 +836,7 @@ describe("SendouForm", () => {
|
||||
member: fieldset({
|
||||
label: "labels.member",
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -854,7 +854,7 @@ describe("SendouForm", () => {
|
||||
member: fieldset({
|
||||
label: "labels.member",
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
@@ -876,7 +876,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 5,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -893,7 +893,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 5,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -914,7 +914,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 5,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -939,7 +939,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 5,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -959,7 +959,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 5,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -985,7 +985,7 @@ describe("SendouForm", () => {
|
||||
label: "labels.urls",
|
||||
min: 0,
|
||||
max: 2,
|
||||
field: textFieldRequired({ maxLength: 100 }),
|
||||
field: textField({ maxLength: 100 }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1007,7 +1007,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1029,7 +1029,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1056,7 +1056,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1078,7 +1078,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1109,7 +1109,7 @@ describe("SendouForm", () => {
|
||||
sortable: true,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1147,7 +1147,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1173,7 +1173,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
role: select({
|
||||
label: "labels.staffRole",
|
||||
items: [
|
||||
@@ -1222,7 +1222,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1251,7 +1251,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
role: select({
|
||||
label: "labels.staffRole",
|
||||
items: [
|
||||
@@ -1270,7 +1270,7 @@ describe("SendouForm", () => {
|
||||
path: "/",
|
||||
element: (
|
||||
<SendouForm schema={schema} onApply={onApply}>
|
||||
{({ names }) => <FormField name={names.staff} />}
|
||||
<FormField name="staff" />
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
@@ -1296,7 +1296,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
description: textAreaOptional({
|
||||
label: "labels.description",
|
||||
maxLength: 500,
|
||||
@@ -1324,7 +1324,7 @@ describe("SendouForm", () => {
|
||||
max: 10,
|
||||
field: fieldset({
|
||||
fields: z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -1416,12 +1416,8 @@ describe("SendouForm", () => {
|
||||
path: "/",
|
||||
element: (
|
||||
<SendouForm schema={schema} defaultValues={defaultValues}>
|
||||
{({ names }) => (
|
||||
<>
|
||||
<FormField name={names.members} />
|
||||
<ValueCapture />
|
||||
</>
|
||||
)}
|
||||
<FormField name="members" />
|
||||
<ValueCapture />
|
||||
</SendouForm>
|
||||
),
|
||||
},
|
||||
@@ -1459,7 +1455,7 @@ describe("SendouForm", () => {
|
||||
describe("render isolation", () => {
|
||||
test("typing in one field does not re-render sibling fields", async () => {
|
||||
const schema = z.object({
|
||||
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
|
||||
name: textField({ label: "labels.name", maxLength: 100 }),
|
||||
bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import type { FetcherWithComponents } from "react-router";
|
||||
import { useFetcher, useLocation } from "react-router";
|
||||
import { isPlainObject } from "remeda";
|
||||
import type { z } from "zod";
|
||||
import type { SendouButtonProps } from "~/components/elements/Button";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { FormField as FormFieldComponent } from "./FormField";
|
||||
import { getFormFieldMetadata } from "./fields";
|
||||
import styles from "./SendouForm.module.css";
|
||||
import type { TypedFormFieldComponent } from "./types";
|
||||
import { useUnsavedChangesChecker } from "./UnsavedChangesGuard";
|
||||
import {
|
||||
buildFieldPath,
|
||||
errorMessageId,
|
||||
@@ -37,7 +39,6 @@ export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
|
||||
setClientError: (name: string, error: string | undefined) => void;
|
||||
clearServerError: (name: string) => void;
|
||||
onFieldChange?: (name: string, newValue: unknown) => void;
|
||||
hideRequiredIndicator: boolean;
|
||||
readOnly: boolean;
|
||||
values: Record<string, unknown>;
|
||||
setValue: (name: string, value: unknown) => void;
|
||||
@@ -56,9 +57,12 @@ export interface FormContextValue<T extends z.ZodRawShape = z.ZodRawShape> {
|
||||
interface FormStore {
|
||||
values: Record<string, unknown>;
|
||||
clientErrors: Partial<Record<string, string>>;
|
||||
/** Has the user edited any field since mount / the last successful submit? */
|
||||
dirty: boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
setValues: (values: Record<string, unknown>) => void;
|
||||
setClientErrors: (errors: Partial<Record<string, string>>) => void;
|
||||
setDirty: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
type FormFieldContextValue = Omit<
|
||||
@@ -72,27 +76,27 @@ const FormContext = React.createContext<FormFieldContextValue | null>(null);
|
||||
|
||||
export const EMPTY_FORM_STORE = createFormStore({}, {});
|
||||
|
||||
type FormNames<T extends z.ZodRawShape> = {
|
||||
[K in keyof T]: K;
|
||||
};
|
||||
|
||||
export interface FormRenderProps<T extends z.ZodRawShape> {
|
||||
names: FormNames<T>;
|
||||
FormField: TypedFormFieldComponent<T>;
|
||||
}
|
||||
|
||||
export type FormMode = "submit" | "autoSubmit" | "client";
|
||||
|
||||
type BaseFormProps<T extends z.ZodRawShape> = {
|
||||
children: React.ReactNode | ((props: FormRenderProps<T>) => React.ReactNode);
|
||||
schema: z.ZodObject<T>;
|
||||
title?: React.ReactNode;
|
||||
submitButtonText?: React.ReactNode;
|
||||
action?: string;
|
||||
method?: "post" | "get";
|
||||
_action?: string;
|
||||
submitButtonTestId?: string;
|
||||
autoSubmit?: boolean;
|
||||
autoApply?: boolean;
|
||||
/** Styling of the submit button, for forms embedded somewhere the default button is too heavy. */
|
||||
submitButtonVariant?: SendouButtonProps["variant"];
|
||||
submitButtonSize?: SendouButtonProps["size"];
|
||||
revalidateRoot?: boolean;
|
||||
/**
|
||||
* Replaces the default form layout classes entirely (it does not merge with
|
||||
* them), so `fullWidth` has no effect when this is set.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* When true, opts out of the default centered, max-width layout so the form
|
||||
@@ -100,18 +104,11 @@ type BaseFormProps<T extends z.ZodRawShape> = {
|
||||
* layout that already controls width/alignment.
|
||||
*/
|
||||
fullWidth?: boolean;
|
||||
/**
|
||||
* When true, fields don't show the red `*` required indicator next to their
|
||||
* label. Useful on pages where every field is required and the asterisk only
|
||||
* adds noise (e.g. the settings page).
|
||||
*/
|
||||
hideRequiredIndicator?: boolean;
|
||||
/**
|
||||
* When true, renders the form for viewing only: every field is disabled and
|
||||
* the submit button is hidden.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
|
||||
secondarySubmit?: React.ReactNode;
|
||||
/**
|
||||
* Called once after a server submission completes successfully (the action
|
||||
@@ -121,22 +118,43 @@ type BaseFormProps<T extends z.ZodRawShape> = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* How submitting works:
|
||||
* - `"submit"` (default): the user submits via the submit button. Values go to
|
||||
* the server, or to `onApply` when provided.
|
||||
* - `"autoSubmit"`: no submit button; every change that passes validation is
|
||||
* sent to the server.
|
||||
* - `"client"`: no submit button and no `<form>` element; every change is
|
||||
* passed to `onApply` and field errors are computed already on mount.
|
||||
*/
|
||||
type FormModeProps<T extends z.ZodRawShape> =
|
||||
| {
|
||||
mode?: "submit";
|
||||
/** When set, a valid submit is handed to this callback instead of being sent to the server. */
|
||||
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
|
||||
}
|
||||
| { mode: "autoSubmit"; onApply?: never }
|
||||
| { mode: "client"; onApply: (values: z.infer<z.ZodObject<T>>) => void };
|
||||
|
||||
export type FormDefaultValues<T extends z.ZodRawShape> = Partial<
|
||||
z.input<z.ZodObject<T>>
|
||||
>;
|
||||
|
||||
type SendouFormProps<T extends z.ZodRawShape> = BaseFormProps<T> &
|
||||
FormModeProps<T> &
|
||||
(HasRequiredDefaults<T> extends true
|
||||
? {
|
||||
defaultValues: Partial<z.input<z.ZodObject<T>>> &
|
||||
defaultValues: FormDefaultValues<T> &
|
||||
Record<RequiredDefaultKeys<T>, unknown>;
|
||||
}
|
||||
: { defaultValues?: Partial<z.input<z.ZodObject<T>>> | null });
|
||||
: { defaultValues?: FormDefaultValues<T> | null });
|
||||
|
||||
interface LatestFormProps {
|
||||
schema: z.ZodObject<z.ZodRawShape>;
|
||||
onApply: ((values: Record<string, unknown>) => void) | undefined;
|
||||
method: "post" | "get";
|
||||
action: string | undefined;
|
||||
revalidateRoot: boolean | undefined;
|
||||
autoSubmit: boolean | undefined;
|
||||
autoApply: boolean | undefined;
|
||||
mode: FormMode;
|
||||
fetcher: FetcherWithComponents<{ fieldErrors?: Record<string, string> }>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
@@ -148,16 +166,14 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
title,
|
||||
submitButtonText,
|
||||
action,
|
||||
method = "post",
|
||||
_action,
|
||||
submitButtonTestId,
|
||||
autoSubmit,
|
||||
autoApply,
|
||||
submitButtonVariant,
|
||||
submitButtonSize,
|
||||
revalidateRoot,
|
||||
className,
|
||||
fullWidth,
|
||||
hideRequiredIndicator = false,
|
||||
readOnly = false,
|
||||
mode = "submit",
|
||||
onApply,
|
||||
secondarySubmit,
|
||||
onSuccess,
|
||||
@@ -175,7 +191,9 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
const initialValues = buildInitialValues(schema, defaultValues);
|
||||
storeRef.current = createFormStore(
|
||||
initialValues,
|
||||
autoApply ? computeTopLevelFieldErrors(schema, initialValues) : {},
|
||||
mode === "client"
|
||||
? computeTopLevelFieldErrors(schema, initialValues)
|
||||
: {},
|
||||
);
|
||||
}
|
||||
const store = storeRef.current;
|
||||
@@ -183,11 +201,9 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
const latestProps: LatestFormProps = {
|
||||
schema: schema as z.ZodObject<z.ZodRawShape>,
|
||||
onApply: onApply as unknown as LatestFormProps["onApply"],
|
||||
method,
|
||||
action,
|
||||
revalidateRoot,
|
||||
autoSubmit,
|
||||
autoApply,
|
||||
mode,
|
||||
fetcher,
|
||||
t: t as unknown as LatestFormProps["t"],
|
||||
};
|
||||
@@ -215,6 +231,7 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
|
||||
store.setValues(buildInitialValues(schema, defaultValues));
|
||||
store.setClientErrors({});
|
||||
store.setDirty(false);
|
||||
setHasSubmitted(false);
|
||||
setFallbackError(null);
|
||||
}, [locationKey]);
|
||||
@@ -243,13 +260,17 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
|
||||
setFallbackError(null);
|
||||
|
||||
const firstErrorField = errorEntries[0][0];
|
||||
const firstErrorElement = document.getElementById(
|
||||
errorMessageId(firstErrorField),
|
||||
const firstError = findFirstErrorElementInDomOrder(
|
||||
errorEntries.map(([fieldName]) => fieldName),
|
||||
);
|
||||
firstErrorElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (firstError) focusAndScrollToError(firstError);
|
||||
}, [fetcher.data, t]);
|
||||
|
||||
const hasUnsavedChangesRef = React.useRef<() => boolean>(() => false);
|
||||
hasUnsavedChangesRef.current = () =>
|
||||
mode === "submit" && !readOnly && store.dirty && fetcher.state === "idle";
|
||||
useUnsavedChangesChecker(hasUnsavedChangesRef);
|
||||
|
||||
const previousFetcherStateRef = React.useRef(fetcher.state);
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -257,10 +278,11 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
fetcher.state === "idle" &&
|
||||
!fetcher.data?.fieldErrors
|
||||
) {
|
||||
store.setDirty(false);
|
||||
onSuccess?.();
|
||||
}
|
||||
previousFetcherStateRef.current = fetcher.state;
|
||||
}, [fetcher.state, fetcher.data, onSuccess]);
|
||||
}, [fetcher.state, fetcher.data, onSuccess, store]);
|
||||
|
||||
const contextValue = React.useMemo<FormFieldContextValue>(
|
||||
() => ({
|
||||
@@ -270,9 +292,7 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
hasSubmitted,
|
||||
setClientError: actions.setClientError,
|
||||
clearServerError: actions.clearServerError,
|
||||
onFieldChange:
|
||||
autoSubmit || autoApply ? actions.onFieldChange : undefined,
|
||||
hideRequiredIndicator,
|
||||
onFieldChange: mode !== "submit" ? actions.onFieldChange : undefined,
|
||||
readOnly,
|
||||
setValue: actions.setValue,
|
||||
setValueFromPrev: actions.setValueFromPrev,
|
||||
@@ -286,9 +306,7 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
defaultValues,
|
||||
visibleServerErrors,
|
||||
hasSubmitted,
|
||||
autoSubmit,
|
||||
autoApply,
|
||||
hideRequiredIndicator,
|
||||
mode,
|
||||
readOnly,
|
||||
fetcher.state,
|
||||
store,
|
||||
@@ -296,14 +314,9 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
],
|
||||
);
|
||||
|
||||
const names = Object.fromEntries(
|
||||
Object.keys(schema.shape).map((key) => [key, key]),
|
||||
) as FormNames<T>;
|
||||
|
||||
const resolvedChildren =
|
||||
typeof children === "function"
|
||||
? children({
|
||||
names,
|
||||
FormField: FormFieldComponent as TypedFormFieldComponent<T>,
|
||||
})
|
||||
: children;
|
||||
@@ -312,12 +325,13 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
<>
|
||||
{title ? <h2 className={styles.title}>{title}</h2> : null}
|
||||
<React.Fragment key={locationKey}>{resolvedChildren}</React.Fragment>
|
||||
{autoSubmit || autoApply || readOnly ? null : (
|
||||
{mode !== "submit" || readOnly ? null : (
|
||||
<div className="mt-4 stack horizontal md mx-auto justify-center items-center">
|
||||
<SubmitButton
|
||||
_action={_action}
|
||||
testId={submitButtonTestId}
|
||||
state={fetcher.state}
|
||||
variant={submitButtonVariant}
|
||||
size={submitButtonSize}
|
||||
>
|
||||
{submitButtonText ?? t("submit")}
|
||||
</SubmitButton>
|
||||
@@ -337,11 +351,11 @@ export function SendouForm<T extends z.ZodRawShape>({
|
||||
|
||||
return (
|
||||
<FormContext.Provider value={contextValue}>
|
||||
{autoApply && onApply ? (
|
||||
{mode === "client" ? (
|
||||
<div className={resolvedClassName}>{formContent}</div>
|
||||
) : (
|
||||
<form
|
||||
method={method}
|
||||
method="post"
|
||||
action={action}
|
||||
className={resolvedClassName}
|
||||
noValidate
|
||||
@@ -368,6 +382,7 @@ function createFormStore(
|
||||
const store: FormStore = {
|
||||
values: initialValues,
|
||||
clientErrors: initialClientErrors,
|
||||
dirty: false,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
@@ -380,6 +395,10 @@ function createFormStore(
|
||||
store.clientErrors = errors;
|
||||
notify();
|
||||
},
|
||||
// Read only at navigation time (unsaved-changes guard), so no notify.
|
||||
setDirty(dirty) {
|
||||
store.dirty = dirty;
|
||||
},
|
||||
};
|
||||
|
||||
return store;
|
||||
@@ -410,20 +429,19 @@ function createFormActions({
|
||||
setFallbackError,
|
||||
}: FormActionDeps) {
|
||||
const scrollToFirstError = (errors: Record<string, string>) => {
|
||||
const firstErrorField = Object.keys(errors)[0];
|
||||
if (!firstErrorField) return;
|
||||
const errorFieldNames = Object.keys(errors);
|
||||
if (errorFieldNames.length === 0) return;
|
||||
|
||||
const errorElement = document.getElementById(
|
||||
errorMessageId(firstErrorField),
|
||||
);
|
||||
if (errorElement) {
|
||||
errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const firstError = findFirstErrorElementInDomOrder(errorFieldNames);
|
||||
if (firstError) {
|
||||
focusAndScrollToError(firstError);
|
||||
setFallbackError(null);
|
||||
} else {
|
||||
const firstError = errors[firstErrorField];
|
||||
const firstErrorField = errorFieldNames[0];
|
||||
const firstErrorMessage = errors[firstErrorField];
|
||||
setFallbackError(
|
||||
firstError
|
||||
? `${latest.current.t(firstError)} (${firstErrorField})`
|
||||
firstErrorMessage
|
||||
? `${latest.current.t(firstErrorMessage)} (${firstErrorField})`
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -447,12 +465,12 @@ function createFormActions({
|
||||
};
|
||||
|
||||
const submitValues = (values: Record<string, unknown>) => {
|
||||
const { fetcher, method, action, revalidateRoot } = latest.current;
|
||||
const { fetcher, action, revalidateRoot } = latest.current;
|
||||
const submitted = revalidateRoot
|
||||
? { ...values, revalidateRoot: true }
|
||||
: values;
|
||||
fetcher.submit(submitted as Record<string, string>, {
|
||||
method,
|
||||
method: "post",
|
||||
action,
|
||||
encType: "application/json",
|
||||
});
|
||||
@@ -491,6 +509,7 @@ function createFormActions({
|
||||
};
|
||||
|
||||
const setValue = (name: string, newValue: unknown) => {
|
||||
store.setDirty(true);
|
||||
if (name.includes(".") || name.includes("[")) {
|
||||
store.setValues(
|
||||
setNestedValue(
|
||||
@@ -508,6 +527,7 @@ function createFormActions({
|
||||
name: string,
|
||||
updater: (prev: unknown) => unknown,
|
||||
) => {
|
||||
store.setDirty(true);
|
||||
store.setValues({ ...store.values, [name]: updater(store.values[name]) });
|
||||
};
|
||||
|
||||
@@ -520,13 +540,17 @@ function createFormActions({
|
||||
const submitToServer = (valuesToSubmit: Record<string, unknown>) => {
|
||||
if (!validateAndPrepare()) return;
|
||||
|
||||
// Cleared before `onApply` because it may navigate synchronously (e.g.
|
||||
// calendar filters set search params) — the blocker would otherwise still
|
||||
// see the form as dirty and block that navigation.
|
||||
store.setDirty(false);
|
||||
latest.current.onApply?.(store.values);
|
||||
|
||||
submitValues(valuesToSubmit);
|
||||
};
|
||||
|
||||
const onFieldChange = (changedName: string, changedValue: unknown) => {
|
||||
const { schema, autoSubmit, autoApply, onApply } = latest.current;
|
||||
const { schema, mode, onApply } = latest.current;
|
||||
const isNestedPath = changedName.includes(".") || changedName.includes("[");
|
||||
const updatedValues = isNestedPath
|
||||
? setNestedValue(store.values, changedName, changedValue)
|
||||
@@ -536,9 +560,9 @@ function createFormActions({
|
||||
store.setClientErrors(newErrors);
|
||||
const hasFieldErrors = Object.keys(newErrors).length > 0;
|
||||
|
||||
if (autoApply && onApply) {
|
||||
onApply(updatedValues);
|
||||
} else if (autoSubmit && !hasFieldErrors) {
|
||||
if (mode === "client") {
|
||||
onApply?.(updatedValues);
|
||||
} else if (mode === "autoSubmit" && !hasFieldErrors) {
|
||||
submitValues(updatedValues);
|
||||
}
|
||||
};
|
||||
@@ -549,6 +573,10 @@ function createFormActions({
|
||||
|
||||
const { onApply } = latest.current;
|
||||
if (onApply) {
|
||||
// Cleared before `onApply` because it may navigate synchronously (e.g.
|
||||
// calendar filters set search params) — the blocker would otherwise
|
||||
// still see the form as dirty and block that navigation.
|
||||
store.setDirty(false);
|
||||
onApply(store.values);
|
||||
} else {
|
||||
submitValues(store.values);
|
||||
@@ -681,7 +709,6 @@ export function useFormFieldContext(): FormContextValue {
|
||||
setClientError: context.setClientError,
|
||||
clearServerError: context.clearServerError,
|
||||
onFieldChange: context.onFieldChange,
|
||||
hideRequiredIndicator: context.hideRequiredIndicator,
|
||||
readOnly: context.readOnly,
|
||||
values,
|
||||
setValue: context.setValue,
|
||||
@@ -695,3 +722,54 @@ export function useFormFieldContext(): FormContextValue {
|
||||
export function useOptionalFormFieldContext() {
|
||||
return React.useContext(FormContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* "First" error means first in DOM order, not first in error-map insertion
|
||||
* order — validation collects errors in schema order which does not have to
|
||||
* match the rendered field order.
|
||||
*/
|
||||
function findFirstErrorElementInDomOrder(errorFieldNames: string[]) {
|
||||
const errorElements = errorFieldNames.flatMap((name) => {
|
||||
const element = document.getElementById(errorMessageId(name));
|
||||
return element ? [{ name, element }] : [];
|
||||
});
|
||||
|
||||
errorElements.sort((a, b) =>
|
||||
a.element.compareDocumentPosition(b.element) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
|
||||
return errorElements.at(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves focus to the failing field so keyboard and screen reader users are
|
||||
* taken to the problem, not just scrolled past it. Prefers the control that
|
||||
* references the error via `aria-errormessage`, then any focusable control in
|
||||
* the same wrapper, and as a last resort the error message element itself.
|
||||
*/
|
||||
function focusAndScrollToError({
|
||||
name,
|
||||
element,
|
||||
}: {
|
||||
name: string;
|
||||
element: HTMLElement;
|
||||
}) {
|
||||
const control = document.querySelector<HTMLElement>(
|
||||
`[aria-errormessage="${errorMessageId(name)}"]`,
|
||||
);
|
||||
const focusTarget =
|
||||
control ??
|
||||
element.parentElement?.querySelector<HTMLElement>(
|
||||
"input, select, textarea, button",
|
||||
) ??
|
||||
element;
|
||||
|
||||
if (focusTarget === element) {
|
||||
element.setAttribute("tabindex", "-1");
|
||||
}
|
||||
focusTarget.focus({ preventScroll: true });
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
84
app/form/UnsavedChangesGuard.tsx
Normal file
84
app/form/UnsavedChangesGuard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useBlocker } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
|
||||
const dirtyCheckers = new Set<() => boolean>();
|
||||
|
||||
/**
|
||||
* Confirms navigating away when any mounted form has unsaved changes.
|
||||
* Rendered once in the root layout because react-router supports only a
|
||||
* single active blocker at a time — individual forms register a checker via
|
||||
* `useUnsavedChangesChecker` instead of calling `useBlocker` themselves.
|
||||
*/
|
||||
export function UnsavedChangesGuard() {
|
||||
const { t } = useTranslation(["common", "forms"]);
|
||||
|
||||
const blocker = useBlocker(
|
||||
({ currentLocation, nextLocation }) =>
|
||||
(currentLocation.pathname !== nextLocation.pathname ||
|
||||
currentLocation.search !== nextLocation.search) &&
|
||||
hasUnsavedChanges(),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!hasUnsavedChanges()) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
if (blocker.state !== "blocked") return null;
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={t("forms:unsavedChanges.title")}
|
||||
onClose={() => blocker.reset()}
|
||||
isDismissable
|
||||
>
|
||||
<div className="stack md stack md text-sm text-lighter">
|
||||
{t("forms:unsavedChanges.body")}
|
||||
<div className="stack horizontal md justify-center">
|
||||
<SendouButton variant="outlined" onPress={() => blocker.reset()}>
|
||||
{t("common:actions.cancel")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
variant="destructive"
|
||||
onPress={() => blocker.proceed()}
|
||||
data-testid="discard-changes-button"
|
||||
>
|
||||
{t("forms:unsavedChanges.discard")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a callback reporting whether the calling form currently has
|
||||
* unsaved changes. The ref indirection lets the callback read the latest
|
||||
* form state without re-registering on every render.
|
||||
*/
|
||||
export function useUnsavedChangesChecker(
|
||||
checkerRef: React.RefObject<() => boolean>,
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
const checker = () => checkerRef.current();
|
||||
dirtyCheckers.add(checker);
|
||||
return () => {
|
||||
dirtyCheckers.delete(checker);
|
||||
};
|
||||
}, [checkerRef]);
|
||||
}
|
||||
|
||||
function hasUnsavedChanges() {
|
||||
for (const checker of dirtyCheckers) {
|
||||
if (checker()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -61,8 +61,17 @@ type WithTypedTranslationKeys<T> = Omit<
|
||||
placeholder?: FormsTranslationKey;
|
||||
};
|
||||
|
||||
type TypedItemLabel<V extends string> = {
|
||||
label: FormsTranslationKey | (() => string);
|
||||
value: V;
|
||||
};
|
||||
|
||||
type WithTypedItemLabels<T, V extends string> = Omit<T, "items"> & {
|
||||
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>;
|
||||
items: Array<TypedItemLabel<V>>;
|
||||
};
|
||||
|
||||
type WithTypedItemLabelsWithImage<T, V extends string> = Omit<T, "items"> & {
|
||||
items: Array<TypedItemLabel<V> & { imgSrc?: string }>;
|
||||
};
|
||||
|
||||
type WithTypedDualSelectFields<T, V extends string> = Omit<
|
||||
@@ -89,8 +98,8 @@ function prefixKey(key: FormsTranslationKey | undefined): string | undefined {
|
||||
return key ? `forms:${key}` : undefined;
|
||||
}
|
||||
|
||||
function prefixItems<V extends string>(
|
||||
items: Array<{ label: FormsTranslationKey | (() => string); value: V }>,
|
||||
function prefixItems<V extends string, T extends TypedItemLabel<V>>(
|
||||
items: Array<T>,
|
||||
) {
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
@@ -127,52 +136,46 @@ export function customField<T extends z.ZodType>(
|
||||
});
|
||||
}
|
||||
|
||||
export function textFieldOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
type TextFieldArgs = WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>;
|
||||
|
||||
export function textFieldOptional(args: TextFieldArgs) {
|
||||
const schema =
|
||||
args.validate === "url"
|
||||
? z.url()
|
||||
: safeNullableStringSchema({ min: args.minLength, max: args.maxLength });
|
||||
|
||||
return textFieldRefined(schema, args).register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
placeholder: prefixKey(args.placeholder),
|
||||
required: false,
|
||||
type: "text-field",
|
||||
initialValue: "",
|
||||
});
|
||||
return registerTextField(schema, args, false);
|
||||
}
|
||||
|
||||
export function textFieldRequired(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
export function textField(args: TextFieldArgs) {
|
||||
const schema =
|
||||
args.validate === "url"
|
||||
? z.string().url()
|
||||
: safeStringSchema({ min: args.minLength, max: args.maxLength });
|
||||
|
||||
return textFieldRefined(schema, args).register(formRegistry, {
|
||||
return registerTextField(schema, args, true);
|
||||
}
|
||||
|
||||
function registerTextField<T extends z.ZodType<string | null>>(
|
||||
schema: T,
|
||||
args: TextFieldArgs,
|
||||
required: boolean,
|
||||
): T {
|
||||
const refined = textFieldRefined(schema, args) as z.ZodType<string | null>;
|
||||
return refined.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
placeholder: prefixKey(args.placeholder),
|
||||
required: true,
|
||||
required,
|
||||
type: "text-field",
|
||||
initialValue: "",
|
||||
});
|
||||
}) as T;
|
||||
}
|
||||
|
||||
function textFieldRefined<T extends z.ZodType<string | null>>(
|
||||
@@ -238,102 +241,97 @@ export function inGameName(
|
||||
});
|
||||
}
|
||||
|
||||
type NumberFieldArgs = WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
| "type"
|
||||
| "initialValue"
|
||||
| "required"
|
||||
| "validate"
|
||||
| "inputType"
|
||||
| "maxLength"
|
||||
>
|
||||
> & { maxLength?: number };
|
||||
|
||||
export function numberField(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
| "type"
|
||||
| "initialValue"
|
||||
| "required"
|
||||
| "validate"
|
||||
| "inputType"
|
||||
| "maxLength"
|
||||
>
|
||||
> & { maxLength?: number },
|
||||
args: NumberFieldArgs & { min?: number; max?: number },
|
||||
) {
|
||||
return z.coerce
|
||||
.number()
|
||||
.int({ message: "forms:errors.mustBeWholeNumber" })
|
||||
.nonnegative()
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
required: true,
|
||||
type: "text-field",
|
||||
inputType: "number",
|
||||
initialValue: "",
|
||||
maxLength: args.maxLength ?? 10,
|
||||
});
|
||||
let schema = numberSchema();
|
||||
|
||||
// an empty field coerces to 0, so `min` is also what makes a required number
|
||||
// field reject being left blank
|
||||
if (typeof args.min === "number") {
|
||||
schema = schema.min(args.min, { message: "forms:errors.numberOutOfRange" });
|
||||
}
|
||||
if (typeof args.max === "number") {
|
||||
schema = schema.max(args.max, { message: "forms:errors.numberOutOfRange" });
|
||||
}
|
||||
|
||||
return schema.register(formRegistry, numberFieldMetadata(args, true));
|
||||
}
|
||||
|
||||
export function numberFieldOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-field" }>,
|
||||
| "type"
|
||||
| "initialValue"
|
||||
| "required"
|
||||
| "validate"
|
||||
| "inputType"
|
||||
| "maxLength"
|
||||
>
|
||||
> & { maxLength?: number },
|
||||
) {
|
||||
return z.coerce
|
||||
.number()
|
||||
.int({ message: "forms:errors.mustBeWholeNumber" })
|
||||
.nonnegative()
|
||||
export function numberFieldOptional(args: NumberFieldArgs) {
|
||||
return numberSchema()
|
||||
.optional()
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
required: false,
|
||||
type: "text-field",
|
||||
inputType: "number",
|
||||
initialValue: "",
|
||||
maxLength: args.maxLength ?? 10,
|
||||
});
|
||||
.register(formRegistry, numberFieldMetadata(args, false));
|
||||
}
|
||||
|
||||
export function textAreaOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-area" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return safeNullableStringSchema({ max: args.maxLength }).register(
|
||||
formRegistry,
|
||||
{
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
required: false,
|
||||
type: "text-area",
|
||||
initialValue: "",
|
||||
},
|
||||
);
|
||||
function numberSchema() {
|
||||
return z.coerce
|
||||
.number()
|
||||
.int({ message: "forms:errors.mustBeWholeNumber" })
|
||||
.nonnegative();
|
||||
}
|
||||
|
||||
export function textAreaRequired(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-area" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return safeStringSchema({ max: args.maxLength }).register(formRegistry, {
|
||||
function numberFieldMetadata(args: NumberFieldArgs, required: boolean) {
|
||||
return {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
required: true,
|
||||
required,
|
||||
type: "text-field" as const,
|
||||
inputType: "number" as const,
|
||||
initialValue: "",
|
||||
maxLength: args.maxLength ?? 10,
|
||||
};
|
||||
}
|
||||
|
||||
type TextAreaArgs = WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "text-area" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>;
|
||||
|
||||
export function textAreaOptional(args: TextAreaArgs) {
|
||||
return registerTextArea(
|
||||
safeNullableStringSchema({ max: args.maxLength }),
|
||||
args,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
export function textArea(args: TextAreaArgs) {
|
||||
return registerTextArea(
|
||||
safeStringSchema({ max: args.maxLength }),
|
||||
args,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
function registerTextArea<T extends z.ZodType<string | null>>(
|
||||
schema: T,
|
||||
args: TextAreaArgs,
|
||||
required: boolean,
|
||||
): T {
|
||||
return (schema as z.ZodType<string | null>).register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
required,
|
||||
type: "text-area",
|
||||
initialValue: "",
|
||||
});
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function toggle(
|
||||
@@ -390,7 +388,10 @@ export function select<V extends string>(
|
||||
Omit<FormFieldSelect<"select", V>, "type" | "initialValue" | "clearable">,
|
||||
V
|
||||
>
|
||||
>,
|
||||
> & {
|
||||
/** Value selected when the form has no default value for the field. Defaults to the first item. */
|
||||
initialValue?: V;
|
||||
},
|
||||
) {
|
||||
return itemsSchema(args.items).register(formRegistry, {
|
||||
...args,
|
||||
@@ -398,7 +399,7 @@ export function select<V extends string>(
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
items: prefixItems(args.items),
|
||||
type: "select",
|
||||
initialValue: args.items[0].value,
|
||||
initialValue: args.initialValue ?? args.items[0].value,
|
||||
clearable: false,
|
||||
});
|
||||
}
|
||||
@@ -488,7 +489,7 @@ export function dualSelectOptional<V extends string>(
|
||||
|
||||
export function radioGroup<V extends string>(
|
||||
args: WithTypedTranslationKeys<
|
||||
WithTypedItemLabels<
|
||||
WithTypedItemLabelsWithImage<
|
||||
Omit<FormFieldInputGroup<"radio-group", V>, "type" | "initialValue">,
|
||||
V
|
||||
>
|
||||
@@ -529,93 +530,73 @@ type DateTimeArgs = WithTypedTranslationKeys<
|
||||
maxMessage?: FormsTranslationKey;
|
||||
};
|
||||
|
||||
export function datetimeRequired(args: DateTimeArgs) {
|
||||
function boundedDate(args: DateTimeArgs, schema: z.ZodDate) {
|
||||
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
|
||||
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
|
||||
|
||||
return schema
|
||||
.refine((d) => d >= resolveMin(), {
|
||||
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
|
||||
})
|
||||
.refine((d) => d <= resolveMax(), {
|
||||
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
|
||||
});
|
||||
}
|
||||
|
||||
function datetimeMetadata(
|
||||
args: DateTimeArgs,
|
||||
overrides: { type: "datetime" | "date"; required: boolean },
|
||||
) {
|
||||
return {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
initialValue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function datetime(args: DateTimeArgs) {
|
||||
return z
|
||||
.preprocess(
|
||||
date,
|
||||
z
|
||||
.date({ message: "forms:errors.required" })
|
||||
.refine((d) => d >= resolveMin(), {
|
||||
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
|
||||
})
|
||||
.refine((d) => d <= resolveMax(), {
|
||||
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
|
||||
}),
|
||||
boundedDate(args, z.date({ message: "forms:errors.required" })),
|
||||
)
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "datetime",
|
||||
initialValue: null,
|
||||
required: true,
|
||||
});
|
||||
.register(
|
||||
formRegistry,
|
||||
datetimeMetadata(args, { type: "datetime", required: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function datetimeOptional(args: DateTimeArgs) {
|
||||
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
|
||||
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
|
||||
|
||||
return z
|
||||
.preprocess(
|
||||
date,
|
||||
z
|
||||
.date()
|
||||
.refine((d) => d >= resolveMin(), {
|
||||
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
|
||||
})
|
||||
.refine((d) => d <= resolveMax(), {
|
||||
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
|
||||
})
|
||||
.nullish(),
|
||||
)
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "datetime",
|
||||
initialValue: null,
|
||||
required: false,
|
||||
});
|
||||
.preprocess(date, boundedDate(args, z.date()).nullish())
|
||||
.register(
|
||||
formRegistry,
|
||||
datetimeMetadata(args, { type: "datetime", required: false }),
|
||||
);
|
||||
}
|
||||
|
||||
export function dayMonthYearRequired(args: DateTimeArgs) {
|
||||
const resolveMin = args.min ?? (() => new Date(Date.UTC(2015, 4, 28)));
|
||||
const resolveMax = args.max ?? (() => new Date(Date.UTC(2030, 4, 28)));
|
||||
|
||||
export function dayMonthYear(args: DateTimeArgs) {
|
||||
return z
|
||||
.preprocess(
|
||||
date,
|
||||
z
|
||||
.date({ message: "forms:errors.required" })
|
||||
.refine((d) => d >= resolveMin(), {
|
||||
message: `forms:${args.minMessage ?? "errors.dateTooEarly"}`,
|
||||
})
|
||||
.refine((d) => d <= resolveMax(), {
|
||||
message: `forms:${args.maxMessage ?? "errors.dateTooLate"}`,
|
||||
}),
|
||||
boundedDate(args, z.date({ message: "forms:errors.required" })),
|
||||
)
|
||||
.transform((d) => ({
|
||||
day: d.getDate(),
|
||||
month: d.getMonth(),
|
||||
year: d.getFullYear(),
|
||||
}))
|
||||
.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "date",
|
||||
initialValue: null,
|
||||
required: true,
|
||||
});
|
||||
.register(
|
||||
formRegistry,
|
||||
datetimeMetadata(args, { type: "date", required: true }),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkboxGroup<V extends string>(
|
||||
args: WithTypedTranslationKeys<
|
||||
WithTypedItemLabels<
|
||||
WithTypedItemLabelsWithImage<
|
||||
Omit<FormFieldInputGroup<"checkbox-group", V>, "type" | "initialValue">,
|
||||
V
|
||||
>
|
||||
@@ -673,33 +654,45 @@ export function weaponPool(
|
||||
});
|
||||
}
|
||||
|
||||
export function stringConstant<T extends string>(value: T) {
|
||||
/**
|
||||
* Field that renders no control at all. Use it for values the form needs to
|
||||
* submit but the user never edits, e.g. a discriminator seeded from the loader.
|
||||
*
|
||||
* Pass `initialValue` to hardcode the starting value. Omitting it makes the
|
||||
* field require a matching entry in the form's `defaultValues`.
|
||||
*/
|
||||
export function hidden<T extends z.ZodType>(
|
||||
schema: T,
|
||||
initialValue: z.input<T>,
|
||||
): T;
|
||||
export function hidden<T extends z.ZodType>(schema: T): RequiresDefault<T>;
|
||||
export function hidden<T extends z.ZodType>(
|
||||
schema: T,
|
||||
initialValue?: z.input<T>,
|
||||
) {
|
||||
// @ts-expect-error Complex generic type with registry
|
||||
return z.literal(value).register(formRegistry, {
|
||||
type: "string-constant",
|
||||
initialValue: value,
|
||||
value,
|
||||
});
|
||||
return schema.register(formRegistry, {
|
||||
type: "hidden",
|
||||
initialValue,
|
||||
}) as never;
|
||||
}
|
||||
|
||||
export function stringConstant<T extends string>(value: T) {
|
||||
return hidden(z.literal(value), value);
|
||||
}
|
||||
|
||||
export function idConstant<T extends number>(value: T): z.ZodLiteral<T>;
|
||||
export function idConstant(): RequiresDefault<z.ZodNumber>;
|
||||
export function idConstant<T extends number>(value?: T) {
|
||||
const schema = value !== undefined ? z.literal(value) : id.clone();
|
||||
return schema.register(formRegistry, {
|
||||
type: "id-constant",
|
||||
initialValue: value,
|
||||
value: value ?? null,
|
||||
}) as never;
|
||||
return (
|
||||
value !== undefined ? hidden(z.literal(value), value) : hidden(id.clone())
|
||||
) as never;
|
||||
}
|
||||
|
||||
export function idConstantOptional<T extends number>(value?: T) {
|
||||
const schema = value ? z.literal(value).optional() : id.optional();
|
||||
return schema.register(formRegistry, {
|
||||
type: "id-constant",
|
||||
initialValue: value,
|
||||
value: value ?? null,
|
||||
});
|
||||
return value
|
||||
? hidden(z.literal(value).optional(), value)
|
||||
: hidden(id.optional(), undefined);
|
||||
}
|
||||
|
||||
export function array<S extends z.ZodType>(
|
||||
@@ -764,40 +757,30 @@ export function fieldset<S extends z.ZodRawShape>(
|
||||
});
|
||||
}
|
||||
|
||||
export function userSearch(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "user-search" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return id.clone().register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "user-search",
|
||||
initialValue: null,
|
||||
required: true,
|
||||
});
|
||||
type UserSearchArgs = WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "user-search" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>;
|
||||
|
||||
export function userSearch(args: UserSearchArgs) {
|
||||
return id.clone().register(formRegistry, userSearchMetadata(args, true));
|
||||
}
|
||||
|
||||
export function userSearchOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "user-search" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return id.optional().register(formRegistry, {
|
||||
export function userSearchOptional(args: UserSearchArgs) {
|
||||
return id.optional().register(formRegistry, userSearchMetadata(args, false));
|
||||
}
|
||||
|
||||
function userSearchMetadata(args: UserSearchArgs, required: boolean) {
|
||||
return {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "user-search",
|
||||
type: "user-search" as const,
|
||||
initialValue: null,
|
||||
required: false,
|
||||
});
|
||||
required,
|
||||
};
|
||||
}
|
||||
|
||||
export function tournamentSearchOptional(
|
||||
@@ -873,38 +856,30 @@ export function stageSelect(
|
||||
});
|
||||
}
|
||||
|
||||
export function weaponSelect(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "weapon-select" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return weaponSplId.register(formRegistry, {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "weapon-select",
|
||||
initialValue: null,
|
||||
required: true,
|
||||
});
|
||||
type WeaponSelectArgs = WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "weapon-select" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>;
|
||||
|
||||
export function weaponSelect(args: WeaponSelectArgs) {
|
||||
return weaponSplId.register(formRegistry, weaponSelectMetadata(args, true));
|
||||
}
|
||||
|
||||
export function weaponSelectOptional(
|
||||
args: WithTypedTranslationKeys<
|
||||
Omit<
|
||||
Extract<FormField, { type: "weapon-select" }>,
|
||||
"type" | "initialValue" | "required"
|
||||
>
|
||||
>,
|
||||
) {
|
||||
return weaponSplId.optional().register(formRegistry, {
|
||||
export function weaponSelectOptional(args: WeaponSelectArgs) {
|
||||
return weaponSplId
|
||||
.optional()
|
||||
.register(formRegistry, weaponSelectMetadata(args, false));
|
||||
}
|
||||
|
||||
function weaponSelectMetadata(args: WeaponSelectArgs, required: boolean) {
|
||||
return {
|
||||
...args,
|
||||
label: prefixKey(args.label),
|
||||
bottomText: prefixKey(args.bottomText),
|
||||
type: "weapon-select",
|
||||
type: "weapon-select" as const,
|
||||
initialValue: null,
|
||||
required: false,
|
||||
});
|
||||
required,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type ArrayFormFieldProps = Omit<FormFieldProps<"array">, "field"> & {
|
||||
itemInitialValue?: unknown;
|
||||
addable?: boolean;
|
||||
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ArrayFormField({
|
||||
@@ -35,6 +36,7 @@ export function ArrayFormField({
|
||||
itemInitialValue,
|
||||
addable = true,
|
||||
canRemoveItem,
|
||||
disabled,
|
||||
}: ArrayFormFieldProps) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { translatedLabel, translatedBottomText, translatedError } =
|
||||
@@ -93,6 +95,7 @@ export function ArrayFormField({
|
||||
// so it shouldn't offer a remove button (you can't go below one visible row
|
||||
// anyway). A lone edited row stays removable so the only item can be cleared.
|
||||
const canRemoveAt = (index: number) =>
|
||||
!disabled &&
|
||||
(canRemoveItem ? canRemoveItem(value[index], index) : true) &&
|
||||
count > min &&
|
||||
(count > minVisible || !isPristineItem(value[index]));
|
||||
@@ -112,7 +115,7 @@ export function ArrayFormField({
|
||||
|
||||
// Sorting is only offered for object arrays; primitive arrays are rendered
|
||||
// inline without the fieldset header that carries the reorder controls.
|
||||
const isSortable = Boolean(sortable) && isObjectArray;
|
||||
const isSortable = Boolean(sortable) && isObjectArray && !disabled;
|
||||
|
||||
const handleMoveAt = (index: number, direction: 1 | -1) => {
|
||||
const target = index + direction;
|
||||
@@ -176,7 +179,7 @@ export function ArrayFormField({
|
||||
variant="outlined"
|
||||
icon={<Plus />}
|
||||
onPress={handleAdd}
|
||||
isDisabled={count >= max}
|
||||
isDisabled={count >= max || disabled}
|
||||
className="m-0-auto"
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
|
||||
@@ -8,6 +8,7 @@ type BadgesFormFieldProps = Omit<FormFieldProps<"badges">, "onBlur"> & {
|
||||
onChange: (value: number[]) => void;
|
||||
onBlur?: () => void;
|
||||
options: BadgeOption[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function BadgesFormField({
|
||||
@@ -20,6 +21,7 @@ export function BadgesFormField({
|
||||
onChange,
|
||||
onBlur,
|
||||
options,
|
||||
disabled,
|
||||
}: BadgesFormFieldProps) {
|
||||
const id = React.useId();
|
||||
|
||||
@@ -37,6 +39,7 @@ export function BadgesFormField({
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
maxCount={maxCount}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ type DatetimeFormFieldProps = Omit<
|
||||
value: Date | undefined;
|
||||
onChange: (value: Date | undefined) => void;
|
||||
granularity?: "day" | "minute";
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DatetimeFormField({
|
||||
@@ -24,6 +25,7 @@ export function DatetimeFormField({
|
||||
value,
|
||||
onChange,
|
||||
granularity = "minute",
|
||||
disabled,
|
||||
}: DatetimeFormFieldProps) {
|
||||
const { translatedLabel, translatedError, translatedBottomText } =
|
||||
useTranslatedTexts({ label, error, bottomText });
|
||||
@@ -58,6 +60,7 @@ export function DatetimeFormField({
|
||||
errorId={errorMessageId(name)}
|
||||
bottomText={translatedBottomText}
|
||||
isRequired={required}
|
||||
isDisabled={disabled}
|
||||
value={
|
||||
value
|
||||
? granularity === "day"
|
||||
|
||||
@@ -12,6 +12,7 @@ type DualSelectFormFieldProps<V extends string> = Omit<
|
||||
];
|
||||
value: [V | null, V | null];
|
||||
onChange: (value: [V | null, V | null]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DualSelectFormField<V extends string>({
|
||||
@@ -22,6 +23,7 @@ export function DualSelectFormField<V extends string>({
|
||||
value,
|
||||
onChange,
|
||||
fields,
|
||||
disabled,
|
||||
}: DualSelectFormFieldProps<V>) {
|
||||
return (
|
||||
<div className="stack xs">
|
||||
@@ -33,6 +35,7 @@ export function DualSelectFormField<V extends string>({
|
||||
onChange={(newValue) => onChange([newValue, value[1]])}
|
||||
onBlur={onBlur}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
/>
|
||||
<SelectFormField
|
||||
label={fields[1].label}
|
||||
@@ -41,6 +44,7 @@ export function DualSelectFormField<V extends string>({
|
||||
onChange={(newValue) => onChange([value[0], newValue])}
|
||||
onBlur={onBlur}
|
||||
clearable
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<FormFieldMessages name={name} error={error} bottomText={bottomText} />
|
||||
|
||||
@@ -10,6 +10,7 @@ type FieldsetFormFieldProps<S extends z.ZodRawShape> = Omit<
|
||||
> & {
|
||||
name: string;
|
||||
fields: z.ZodObject<S>;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function FieldsetFormField<S extends z.ZodRawShape>({
|
||||
@@ -18,6 +19,7 @@ export function FieldsetFormField<S extends z.ZodRawShape>({
|
||||
bottomText,
|
||||
error,
|
||||
fields,
|
||||
disabled,
|
||||
}: FieldsetFormFieldProps<S>) {
|
||||
const fieldNames = Object.keys(fields.shape);
|
||||
const { translatedLabel, translatedBottomText, translatedError } =
|
||||
@@ -33,6 +35,7 @@ export function FieldsetFormField<S extends z.ZodRawShape>({
|
||||
key={fieldName}
|
||||
name={`${name}.${fieldName}`}
|
||||
field={fields.shape[fieldName] as z.ZodType}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
{translatedError ? (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user