-
- {!friendCode ? (
-
-
{t("common:fc.title")}
-
-
-
- {t("common:fc.whereToFind")}
-
-
+
+ {({ FormField }) => (
+
+
+
+ {t("common:fc.onceSetStaffOnly")}
+
+
+
+ {t("common:fc.whereToFind")}
-
-
- ) : null}
- {friendCode ? (
- SW-{friendCode}
- ) : (
-
- )}
+
+
+
+
- {!friendCode ? (
-
- {t("common:actions.save")}
-
- ) : null}
-
- {!friendCode ? (
-
- {t("common:fc.onceSetStaffOnly")}
-
- ) : null}
-
+ )}
+
);
}
diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx
index 207a0a1e5..715efcd1f 100644
--- a/app/components/MobileNav.tsx
+++ b/app/components/MobileNav.tsx
@@ -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 (
) : null}
+
) {
+ const { t } = useTranslation(["common"]);
+
+ const canNativeShare =
+ typeof navigator !== "undefined" && typeof navigator.share === "function";
+
+ if (canNativeShare) {
+ return (
+ }
+ onPress={() => navigator.share({ url })}
+ aria-label={t("common:actions.share")}
+ {...buttonProps}
+ />
+ );
+ }
+
+ return (
+ }
+ aria-label={t("common:actions.share")}
+ {...buttonProps}
+ />
+ }
+ />
+ );
+}
diff --git a/app/components/SideNav.module.css b/app/components/SideNav.module.css
index 3e2d4a4e9..b36a14270 100644
--- a/app/components/SideNav.module.css
+++ b/app/components/SideNav.module.css
@@ -268,6 +268,7 @@
.listLinkSubtitleRow {
display: flex;
align-items: center;
+ gap: var(--s-1-5);
width: 100%;
color: var(--color-text-high);
}
diff --git a/app/components/StageSelect.tsx b/app/components/StageSelect.tsx
index a25dddc68..058afc27c 100644
--- a/app/components/StageSelect.tsx
+++ b/app/components/StageSelect.tsx
@@ -16,6 +16,7 @@ interface StageSelectProps {
clearable?: Clearable;
testId?: string;
isRequired?: boolean;
+ isDisabled?: boolean;
}
export function StageSelect({
@@ -26,6 +27,7 @@ export function StageSelect({
clearable,
testId = "stage-select",
isRequired,
+ isDisabled,
}: StageSelectProps) {
const { t } = useTranslation(["common", "game-misc"]);
const items = useStageItems();
@@ -54,6 +56,7 @@ export function StageSelect({
clearable={clearable}
data-testid={testId}
isRequired={isRequired}
+ isDisabled={isDisabled}
>
{({ id, name }) => (
diff --git a/app/components/match-page/MatchBannerStartedAt.tsx b/app/components/match-page/MatchBannerStartedAt.tsx
index 53601cdb1..20347355a 100644
--- a/app/components/match-page/MatchBannerStartedAt.tsx
+++ b/app/components/match-page/MatchBannerStartedAt.tsx
@@ -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 (
+
+ );
+ }
+
return (
);
diff --git a/app/components/match-page/MatchTimeline.tsx b/app/components/match-page/MatchTimeline.tsx
index 5c022a127..bfb78c12c 100644
--- a/app/components/match-page/MatchTimeline.tsx
+++ b/app/components/match-page/MatchTimeline.tsx
@@ -185,7 +185,7 @@ function TimelineHeader({
) : null}
{isOngoing ? (
- {t("q:match.timeline.live")}
+ {t("q:match.timeline.ongoing")}
) : null}
diff --git a/app/features/admin/actions/admin.server.ts b/app/features/admin/actions/admin.server.ts
index f3c50e7f5..45a8d8092 100644
--- a/app/features/admin/actions/admin.server.ts
+++ b/app/features/admin/actions/admin.server.ts
@@ -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()),
- }),
-]);
diff --git a/app/features/admin/admin-constants.ts b/app/features/admin/admin-constants.ts
index 0ddc89efe..3a72e5175 100644
--- a/app/features/admin/admin-constants.ts
+++ b/app/features/admin/admin-constants.ts
@@ -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;
diff --git a/app/features/admin/admin-schemas.ts b/app/features/admin/admin-schemas.ts
index bc587733e..6a6a4b1a5 100644
--- a/app/features/admin/admin-schemas.ts
+++ b/app/features/admin/admin-schemas.ts
@@ -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({
diff --git a/app/features/admin/routes/admin.test.ts b/app/features/admin/routes/admin.test.ts
index dbbf0e16d..c5eba109b 100644
--- a/app/features/admin/routes/admin.test.ts
+++ b/app/features/admin/routes/admin.test.ts
@@ -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
({ action });
+const adminAction = wrappedAction({
+ 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" },
);
diff --git a/app/features/admin/routes/admin.tsx b/app/features/admin/routes/admin.tsx
index 11d40c459..d915d9b97 100644
--- a/app/features/admin/routes/admin.tsx
+++ b/app/features/admin/routes/admin.tsx
@@ -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 (
-
+
Actions
@@ -79,28 +89,17 @@ export default function AdminPage() {
function FriendCodeLookUp() {
const data = useLoaderData();
const [searchParams, setSearchParams] = useSearchParams();
- const [friendCode, setFriendCode] = React.useState(
- searchParams.get("friendCode") ?? "",
- );
- const fetcher = useFetcher();
return (
-
-
- setFriendCode(e.target.value)}
- />
- }
- onPress={() => setSearchParams({ friendCode })}
- >
- Search
-
-
+
+
setSearchParams({ friendCode })}
+ >
+ {({ FormField }) => }
+
{data.friendCodeSearchUsers?.map((user) => (
();
- const [newUserId, setNewUserId] = React.useState
();
- const navigation = useNavigation();
- const fetcher = useFetcher();
-
return (
-
- Migrate user data
-
-
- setOldUserId(newUser?.id)}
- />
-
-
- setNewUserId(newUser?.id)}
- />
-
-
-
-
- Migrate
-
-
-
- Note: data on "New user" will be deleted (e.g. builds)
-
-
+
+ {({ FormField }) => (
+ <>
+
+
+ >
+ )}
+
);
}
function LinkPlayer() {
- const fetcher = useFetcher();
-
return (
-
- Link player
-
-
-
-
-
- Player ID
-
-
-
-
-
- Link player
-
-
-
+
+ {({ FormField }) => (
+ <>
+
+
+ >
+ )}
+
);
}
function GiveArtist() {
- const fetcher = useFetcher();
-
return (
-
- Add as artist
-
-
-
-
-
- Add as artist
-
-
-
+
+ {({ FormField }) => }
+
);
}
function GiveVideoAdder() {
- const fetcher = useFetcher();
-
return (
-
- Give video adder
-
-
-
-
-
- Add as video adder
-
-
-
+
+ {({ FormField }) => }
+
);
}
function GiveTournamentOrganizer() {
- const fetcher = useFetcher();
-
return (
-
- Give tournament organizer
-
-
-
- Add as tournament organizer
-
-
-
+
+ {({ FormField }) => }
+
);
}
function GiveApiAccess() {
- const fetcher = useFetcher();
-
return (
-
- Give API access
-
-
-
- Grant API access
-
-
-
+
+ {({ FormField }) => }
+
);
}
function UpdateFriendCode() {
- const fetcher = useFetcher();
- const id = React.useId();
-
return (
-
- Update friend code
-
-
-
-
-
- Friend code
-
-
-
-
-
- Submit
-
-
-
+
+ {({ FormField }) => (
+ <>
+
+
+ >
+ )}
+
);
}
function ForcePatron() {
- const fetcher = useFetcher();
-
return (
-
- Force patron
-
-
-
-
-
-
- Tier
-
- Support
- Supporter
- Supporter+
-
-
-
-
- Patron till
-
-
-
-
-
- Save
-
-
-
+
+ {({ FormField }) => (
+ <>
+
+
+
+ >
+ )}
+
);
}
function BanUser() {
- const fetcher = useFetcher();
-
return (
-
- Ban user
-
-
-
-
-
-
- Banned till
-
-
-
-
- Reason
-
-
-
-
-
- Save
-
-
-
+ Ban user}
+ submitButtonText="Save"
+ >
+ {({ FormField }) => (
+ <>
+
+
+
+ >
+ )}
+
);
}
function UnbanUser() {
- const fetcher = useFetcher();
-
return (
-
- Unban user
-
-
-
- Save
-
-
-
+ Unban user}
+ submitButtonText="Save"
+ >
+ {({ FormField }) => }
+
);
}
function RefreshPlusTiers() {
- const fetcher = useFetcher();
-
return (
-
- Refresh Plus Tiers
-
- Refresh
-
-
+
+ {null}
+
);
}
diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts
index bd90d2a9a..3a3794936 100644
--- a/app/features/art/actions/art.new.server.ts
+++ b/app/features/art/actions/art.new.server.ts
@@ -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: {
diff --git a/app/features/art/art-image.server.ts b/app/features/art/art-image.server.ts
new file mode 100644
index 000000000..d227b2405
--- /dev/null
+++ b/app/features/art/art-image.server.ts
@@ -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 `-small.` 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 {
+ 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);
+}
diff --git a/app/features/art/art-image.ts b/app/features/art/art-image.ts
new file mode 100644
index 000000000..eeab10b57
--- /dev/null
+++ b/app/features/art/art-image.ts
@@ -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;
+
+/**
+ * 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;
+}
diff --git a/app/features/art/art-schemas.server.ts b/app/features/art/art-schemas.server.ts
index e8c92401b..015ba59b3 100644
--- a/app/features/art/art-schemas.server.ts
+++ b/app/features/art/art-schemas.server.ts
@@ -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"),
diff --git a/app/features/art/art-schemas.ts b/app/features/art/art-schemas.ts
new file mode 100644
index 000000000..eb0ea3a0d
--- /dev/null
+++ b/app/features/art/art-schemas.ts
@@ -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",
+ });
+ }
+ });
diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx
index 61ab00ca2..f058a598d 100644
--- a/app/features/art/components/ArtGrid.tsx
+++ b/app/features/art/components/ArtGrid.tsx
@@ -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 ? (
{art.linkedUsers?.map((user) => (
void; art: ListedArt }) {
{art.description ? (
{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}
@@ -244,7 +246,7 @@ function ImagePreview({
{uploadDateText ? (
{uploadDateText}
@@ -279,7 +281,7 @@ function ImagePreview({
@@ -288,7 +290,7 @@ function ImagePreview({
{uploadDateText ? (
{uploadDateText}
diff --git a/app/features/art/components/ArtImageFormField.module.css b/app/features/art/components/ArtImageFormField.module.css
new file mode 100644
index 000000000..7922e577f
--- /dev/null
+++ b/app/features/art/components/ArtImageFormField.module.css
@@ -0,0 +1,3 @@
+.preview {
+ max-width: 100%;
+}
diff --git a/app/features/art/components/ArtImageFormField.tsx b/app/features/art/components/ArtImageFormField.tsx
new file mode 100644
index 000000000..4752113d3
--- /dev/null
+++ b/app/features/art/components/ArtImageFormField.tsx
@@ -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
,
+ "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();
+ const { t } = useTranslation(["common"]);
+
+ if (value?.type === "EXISTING") {
+ return ;
+ }
+
+ const handleFileChange = async (
+ event: React.ChangeEvent,
+ ) => {
+ 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 (
+
+
+
+ {value ? (
+
+ ) : null}
+
+
+ );
+}
+
+function compressToDataUrl(
+ file: File,
+ options: Compressor.Options,
+): Promise {
+ 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,
+ });
+ });
+}
diff --git a/app/features/art/components/ArtTagsFormField.module.css b/app/features/art/components/ArtTagsFormField.module.css
new file mode 100644
index 000000000..36bd0a006
--- /dev/null
+++ b/app/features/art/components/ArtTagsFormField.module.css
@@ -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;
+}
diff --git a/app/features/art/components/ArtTagsFormField.tsx b/app/features/art/components/ArtTagsFormField.tsx
new file mode 100644
index 000000000..6e26f157e
--- /dev/null
+++ b/app/features/art/components/ArtTagsFormField.tsx
@@ -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, "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 (
+
+
+ {value.length >= ART.TAGS_MAX_LENGTH ? (
+
+ {t("art:forms.tags.maxReached")}
+
+ ) : creationMode ? (
+ <>
+
+ setNewTagValue(e.target.value)}
+ onKeyDown={(event) => {
+ if (event.code === "Enter") {
+ handleAddNewTag();
+ }
+ }}
+ />
+
+ {t("common:actions.add")}
+
+
+
+ setCreationMode(false)}
+ >
+ {t("art:forms.tags.selectFromExisting")}
+
+
+ >
+ ) : (
+ <>
+
tag.id)
+ .filter((id) => id !== undefined)}
+ onSelectionChange={(tagName) =>
+ onChange([
+ ...value,
+ existingTags.find((tag) => tag.name === tagName)!,
+ ])
+ }
+ />
+
+ {t("art:forms.tags.cantFindExisting")}
+ setCreationMode(true)}
+ >
+ {t("art:forms.tags.addNew")}
+
+
+ >
+ )}
+ {value.length > 0 ? (
+
+ {value.map((tag) => (
+
+ {tag.name}
+ }
+ size="miniscule"
+ variant="minimal-destructive"
+ onPress={() =>
+ onChange(value.filter((it) => it.name !== tag.name))
+ }
+ />
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/app/features/art/routes/art.new.tsx b/app/features/art/routes/art.new.tsx
index 44f585e6c..79d935b8d 100644
--- a/app/features/art/routes/art.new.tsx
+++ b/app/features/art/routes/art.new.tsx
@@ -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();
- const [img, setImg] = React.useState(null);
- const [smallImg, setSmallImg] = React.useState(null);
- const { t } = useTranslation(["common", "art"]);
- const ref = React.useRef(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 (
@@ -76,321 +47,51 @@ export default function NewArtPage() {
);
}
+ const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
+
return (
-
+ user.id) ?? [],
+ isShowcase: isCurrentlyShowcase,
+ }}
+ >
+ {({ FormField }) => (
+ <>
+ {t("art:forms.caveats")}
+
+ {({ value, onChange, error }: CustomFieldRenderProps) => (
+ void}
+ error={error}
+ />
+ )}
+
+
+
+ {({ value, onChange, error }: CustomFieldRenderProps) => (
+ void}
+ error={error}
+ existingTags={data.tags}
+ />
+ )}
+
+
+ {data.art ? (
+
+ ) : null}
+ >
+ )}
+
);
}
-
-function ImageUpload({
- img,
- setImg,
- setSmallImg,
-}: {
- img: File | null;
- setImg: (file: File | null) => void;
- setSmallImg: (file: File | null) => void;
-}) {
- const data = useLoaderData();
- const { t } = useTranslation(["common"]);
- const id = React.useId();
-
- if (data.art) {
- return ;
- }
-
- return (
-
-
{t("common:upload.imageToUpload")}
-
{
- 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 &&
}
-
- );
-}
-
-function Description() {
- const { t } = useTranslation(["art"]);
- const data = useLoaderData();
- const [value, setValue] = React.useState(data.art?.description ?? "");
- const id = React.useId();
-
- return (
-
-
- {t("art:forms.description.title")}
-
-
- );
-}
-
-// 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();
- 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 (
-
-
- {t("art:forms.tags.title")}
-
-
- {creationMode ? (
-
- setCreationMode(false)}
- >
- {t("art:forms.tags.selectFromExisting")}
-
-
- ) : (
-
- {t("art:forms.tags.cantFindExisting")}{" "}
- setCreationMode(true)}>
- {t("art:forms.tags.addNew")}
-
-
- )}
- {tags.length >= ART.TAGS_MAX_LENGTH ? (
-
- {t("art:forms.tags.maxReached")}
-
- ) : creationMode ? (
-
- setNewTagValue(e.target.value)}
- onKeyDown={(event) => {
- if (event.code === "Enter") {
- handleAddNewTag();
- }
- }}
- />
-
- {t("common:actions.add")}
-
-
- ) : (
-
t.id).filter((id) => id !== undefined)}
- onSelectionChange={(tagName) =>
- setTags([...tags, data.tags.find((t) => t.name === tagName)!])
- }
- />
- )}
-
- {tags.map((t) => {
- return (
-
- {t.name}{" "}
- }
- size="small"
- variant="minimal-destructive"
- className="art__delete-tag-button"
- onPress={() => {
- setTags(tags.filter((tag) => tag.name !== t.name));
- }}
- />
-
- );
- })}
-
-
- );
-}
-
-function LinkedUsers() {
- const { t } = useTranslation(["art"]);
- const data = useLoaderData();
- 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 (
-
-
{t("art:forms.linkedUsers.title")}
-
u.userId).map((u) => u.userId),
- )}
- />
- {users.map(({ inputId, userId }, i) => {
- return (
-
- {
- const newUsers = structuredClone(users);
- newUsers[i] = { ...newUsers[i], userId: newUser?.id };
-
- setUsers(newUsers);
- }}
- initialUserId={userId}
- />
- {users.length > 1 || users[0].userId ? (
- {
- if (users.length === 1) {
- setUsers([{ inputId: nanoid() }]);
- } else {
- setUsers(users.filter((u) => u.inputId !== inputId));
- }
- }}
- icon={ }
- />
- ) : null}
-
- );
- })}
-
setUsers([...users, { inputId: nanoid() }])}
- isDisabled={users.length >= ART.LINKED_USERS_MAX_LENGTH}
- className="my-3"
- variant="outlined"
- >
- {t("art:forms.linkedUsers.anotherOne")}
-
-
{t("art:forms.linkedUsers.info")}
-
- );
-}
-
-function ShowcaseToggle() {
- const { t } = useTranslation(["art"]);
- const data = useLoaderData();
- const isCurrentlyShowcase = Boolean(data.art?.isShowcase);
- const [checked, setChecked] = React.useState(isCurrentlyShowcase);
- const id = React.useId();
-
- return (
-
- {t("art:forms.showcase.title")}
-
- {t("art:forms.showcase.info")}
-
- );
-}
diff --git a/app/features/associations/associations-schemas.ts b/app/features/associations/associations-schemas.ts
index 288dde12f..104c53b81 100644
--- a/app/features/associations/associations-schemas.ts
+++ b/app/features/associations/associations-schemas.ts
@@ -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,
}),
diff --git a/app/features/badges/components/BadgesSelector.tsx b/app/features/badges/components/BadgesSelector.tsx
index 13f312ba3..7fe2ff39d 100644
--- a/app/features/badges/components/BadgesSelector.tsx
+++ b/app/features/badges/components/BadgesSelector.tsx
@@ -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"
>
{t("common:badges.selector.select")}
diff --git a/app/features/build-analyzer/analyzer-constants.ts b/app/features/build-analyzer/analyzer-constants.ts
index 85db0e0a0..b97ed53c8 100644
--- a/app/features/build-analyzer/analyzer-constants.ts
+++ b/app/features/build-analyzer/analyzer-constants.ts
@@ -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,
+};
diff --git a/app/features/build-analyzer/analyzer-types.ts b/app/features/build-analyzer/analyzer-types.ts
index 4ca6dc007..e5321d504 100644
--- a/app/features/build-analyzer/analyzer-types.ts
+++ b/app/features/build-analyzer/analyzer-types.ts
@@ -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;
mainWeaponWhiteInkSeconds?: number;
subWeaponWhiteInkSeconds: number;
subWeaponInkConsumptionPercentage: Stat;
diff --git a/app/features/build-analyzer/core/stats.test.ts b/app/features/build-analyzer/core/stats.test.ts
index fbc265af2..8bd8b5fb0 100644
--- a/app/features/build-analyzer/core/stats.test.ts
+++ b/app/features/build-analyzer/core/stats.test.ts
@@ -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],
diff --git a/app/features/build-analyzer/core/stats.ts b/app/features/build-analyzer/core/stats.ts
index 8fa6984a3..53507c8c8 100644
--- a/app/features/build-analyzer/core/stats.ts
+++ b/app/features/build-analyzer/core/stats.ts
@@ -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(
diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx
index 83c710127..9d34820dc 100644
--- a/app/features/build-analyzer/routes/analyzer.tsx
+++ b/app/features/build-analyzer/routes/analyzer.tsx
@@ -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) => (
+
+ ))
+ : null}
{analyzed.stats.specialDurationInSeconds && (
{/* always render this so it reserves space */}
- {!isStaticValue && (
+ {isStaticValue ? (
+ staticValueAbility ? (
+
+ ) : null
+ ) : (
<>
{
@@ -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));
diff --git a/app/features/calendar/actions/calendar.tsx b/app/features/calendar/actions/calendar.tsx
index fb88e06a2..cca7513ab 100644
--- a/app/features/calendar/actions/calendar.tsx
+++ b/app/features/calendar/actions/calendar.tsx
@@ -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({
diff --git a/app/features/calendar/calendar-constants.ts b/app/features/calendar/calendar-constants.ts
index 65c74de6f..adb183f60 100644
--- a/app/features/calendar/calendar-constants.ts
+++ b/app/features/calendar/calendar-constants.ts
@@ -94,6 +94,8 @@ export const EXCLUDED_TAGS: Array = ["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,
diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts
index 66d86d548..4b332e0c5 100644
--- a/app/features/calendar/calendar-new-schemas.ts
+++ b/app/features/calendar/calendar-new-schemas.ts
@@ -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,
diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts
index a65b72c5d..5a7d9d8db 100644
--- a/app/features/calendar/calendar-schemas.ts
+++ b/app/features/calendar/calendar-schemas.ts
@@ -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;
+
+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 => {
+ 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
diff --git a/app/features/calendar/routes/calendar.$id.report-winners.tsx b/app/features/calendar/routes/calendar.$id.report-winners.tsx
index 6f507f4b3..4d0417375 100644
--- a/app/features/calendar/routes/calendar.$id.report-winners.tsx
+++ b/app/features/calendar/routes/calendar.$id.report-winners.tsx
@@ -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();
return (
-
+ ({
+ teamName: team.teamName,
+ placement: team.placement,
+ players: team.players.map(playerToFormValue),
+ })),
+ }}
+ >
+ {({ FormField }) => (
+ <>
+
+
+ {t("calendar:forms.reportResultsInfo")}
+
+
+ {({ itemName }: ArrayItemRenderContext) => (
+
+
+
+
+ {(props: CustomFieldRenderProps) => (
+
+ )}
+
+
+ )}
+
+ >
+ )}
+
);
}
-function ParticipantsCountInput() {
- const { t } = useTranslation("calendar");
- const data = useLoaderData();
+type LoadedPlayer = Unpacked<
+ Unpacked["winners"]>["players"]
+>;
- return (
-
-
- {t("forms.participantCount")}
-
-
-
- );
+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();
- 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;
- const handleTeamDelete = () => {
- setAmountOfTeams(amountOfTeams - 1);
+ const handlePlayerChange = (index: number, newPlayer: ReportedPlayer) => {
+ onChange(players.map((player, i) => (i === index ? newPlayer : player)));
};
- return (
- <>
-
- {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 (
-
- 1
- ? handleTeamDelete
- : undefined
- }
- hidden={hidden}
- initialPlacement={String(i + 1)}
- initialValues={data.winners[i]}
- />
- {!hidden && }
-
- );
- })}
- setAmountOfTeams((amountOfTeams) => amountOfTeams + 1)}
- size="small"
- >
- {t("forms.team.add")}
-
- >
- );
-}
-
-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["winners"]>;
-}) {
- const { t } = useTranslation("calendar");
- const teamNameId = React.useId();
- const placementId = React.useId();
-
- const [results, setResults] = React.useState({
- 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) => {
- setResults({ ...results, teamName: e.target.value });
- };
-
- const handlePlacementChange = (e: React.ChangeEvent) => {
- setResults({ ...results, placement: e.target.value });
- };
-
- const handleSetPlayers = React.useCallback(
- (action: React.SetStateAction) => {
- setResults((prev) => ({
- ...prev,
- players: typeof action === "function" ? action(prev.players) : action,
- }));
- },
- [],
- );
-
- if (hidden) return null;
-
- return (
-
-
- (typeof player === "string" && player !== "") ||
- (typeof player === "object" && player.id !== 0),
- ),
- })}
- />
-
-
- {onRemoveTeam && (
-
- {t("forms.team.remove")}
-
- )}
-
- );
-}
-
-function Players({
- players,
- setPlayers,
-}: {
- players: TeamResults["players"];
- setPlayers: React.Dispatch>;
-}) {
- 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 (
- {players.map((player, i) => {
- const formId = `player-${i + 1}`;
- const asPlainInput = typeof player === "string";
-
- return (
-
-
-
- {t("forms.team.player.header", { number: i + 1 })}
-
- handlePlayerInputTypeChange(i)}
- >
- {asPlainInput
- ? t("forms.team.player.addAsUser")
- : t("forms.team.player.addAsText")}
-
-
-
-
- );
- })}
+ {players.map((player, i) => (
+
+ ))}
+ {translatedError ? (
+
+ {translatedError}
+
+ ) : null}
onChange([...players, EMPTY_REPORTED_PLAYER])}
isDisabled={
players.length === CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH
}
- variant="outlined"
>
- {t("forms.team.player.add")}
- {" "}
+ {t("calendar:forms.team.player.add")}
+
onChange(players.slice(0, -1))}
isDisabled={players.length === 1}
>
- {t("forms.team.player.remove")}
+ {t("calendar:forms.team.player.remove")}
@@ -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) => {
- 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 (
-
- );
- }
+ const asPlainInput = player.type === "NAME";
+ const label = t("calendar:forms.team.player.header", { number: index + 1 });
return (
-
+
+
+ {player.type === "NAME" ? (
+ <>
+ {label}
+
+ onPlayerChange(index, { type: "NAME", name: e.target.value })
+ }
+ maxLength={CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH}
+ />
+ >
+ ) : (
+
+ onPlayerChange(index, { type: "USER", id: user?.id ?? null })
+ }
+ />
+ )}
+
+
+ onPlayerChange(
+ index,
+ asPlainInput ? EMPTY_REPORTED_PLAYER : { type: "NAME", name: "" },
+ )
+ }
+ >
+ {asPlainInput
+ ? t("calendar:forms.team.player.addAsUser")
+ : t("calendar:forms.team.player.addAsText")}
+
+
);
}
diff --git a/app/features/components-showcase/form-examples-schema.ts b/app/features/components-showcase/form-examples-schema.ts
index dadbc6214..540b3104a 100644
--- a/app/features/components-showcase/form-examples-schema.ts
+++ b/app/features/components-showcase/form-examples-schema.ts
@@ -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({
diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx
index b30e6a8f4..a989b7621 100644
--- a/app/features/components-showcase/routes/components.tsx
+++ b/app/features/components-showcase/routes/components.tsx
@@ -2102,14 +2102,14 @@ function FormFieldsSection({ id }: { id: string }) {
{({ FormField }) => (
Text Fields
-
+
@@ -2123,7 +2123,7 @@ function FormFieldsSection({ id }: { id: string }) {
Text Areas
-
+
@@ -2173,7 +2173,7 @@ function FormFieldsSection({ id }: { id: string }) {
Date & Time
-
+
@@ -2181,7 +2181,7 @@ function FormFieldsSection({ id }: { id: string }) {
-
+
diff --git a/app/features/friends/components/FriendMenu.tsx b/app/features/friends/components/FriendMenu.tsx
index 5b394de32..635fa12d7 100644
--- a/app/features/friends/components/FriendMenu.tsx
+++ b/app/features/friends/components/FriendMenu.tsx
@@ -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;
+
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({
{name}
@@ -88,6 +103,17 @@ export function FriendMenu({
} onAction={onNavigate}>
{t("friends:friendsList.viewUserPage")}
+ {streamUrl ? (
+ }
+ onAction={onNavigate}
+ >
+ {t("friends:friendsList.watchStream")}
+
+ ) : null}
{activity?.type === "join-sendouq" ? (
}
@@ -189,7 +215,7 @@ function resolveActivity(friend: {
}),
} as const)
: null;
- case "TOURNAMENT_PLAYING":
+ case "TOURNAMENT_WAITING":
return friend.tournamentId
? ({
type: "view-tournament",
diff --git a/app/features/friends/friends-constants.ts b/app/features/friends/friends-constants.ts
index 1fe64d496..7500215cf 100644
--- a/app/features/friends/friends-constants.ts
+++ b/app/features/friends/friends-constants.ts
@@ -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 = {
+ 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;
}
diff --git a/app/features/friends/friends-utils.server.ts b/app/features/friends/friends-utils.server.ts
index 25bacaf8e..0e4c4febd 100644
--- a/app/features/friends/friends-utils.server.ts
+++ b/app/features/friends/friends-utils.server.ts
@@ -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();
+ 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;
}): 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,
+) {
+ return players.find(
+ (player) =>
+ streamingParticipantIds.has(player.userId) && player.streamTwitch,
+ )?.streamTwitch;
+}
diff --git a/app/features/friends/loaders/friends.server.ts b/app/features/friends/loaders/friends.server.ts
index 17be3959a..0d0ffbe5a 100644
--- a/app/features/friends/loaders/friends.server.ts
+++ b/app/features/friends/loaders/friends.server.ts
@@ -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"],
diff --git a/app/features/img-upload/image-bytes.server.ts b/app/features/img-upload/image-bytes.server.ts
new file mode 100644
index 000000000..3fb4715bd
--- /dev/null
+++ b/app/features/img-upload/image-bytes.server.ts
@@ -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,
+) {
+ 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;
+}
diff --git a/app/features/img-upload/image-field.server.ts b/app/features/img-upload/image-field.server.ts
index 34423d1eb..83eb42c74 100644
--- a/app/features/img-upload/image-field.server.ts
+++ b/app/features/img-upload/image-field.server.ts
@@ -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;
-}
diff --git a/app/features/img-upload/upload-constants.ts b/app/features/img-upload/upload-constants.ts
index 07cd28ed9..33839ae7b 100644
--- a/app/features/img-upload/upload-constants.ts
+++ b/app/features/img-upload/upload-constants.ts
@@ -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;
diff --git a/app/features/lfg/lfg-schemas.ts b/app/features/lfg/lfg-schemas.ts
index 00e68beef..92b1f4a5f 100644
--- a/app/features/lfg/lfg-schemas.ts
+++ b/app/features/lfg/lfg-schemas.ts
@@ -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,
}),
diff --git a/app/features/plus-suggestions/plus-suggestions-schemas.ts b/app/features/plus-suggestions/plus-suggestions-schemas.ts
index 0a8f4de32..fab49f152 100644
--- a/app/features/plus-suggestions/plus-suggestions-schemas.ts
+++ b/app/features/plus-suggestions/plus-suggestions-schemas.ts
@@ -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,
});
diff --git a/app/features/scrims/actions/scrims.$id.server.ts b/app/features/scrims/actions/scrims.$id.server.ts
index bd1aa7e26..c6b121011 100644
--- a/app/features/scrims/actions/scrims.$id.server.ts
+++ b/app/features/scrims/actions/scrims.$id.server.ts
@@ -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) {
diff --git a/app/features/scrims/actions/scrims.server.ts b/app/features/scrims/actions/scrims.server.ts
index 41c32017d..687ac7099 100644
--- a/app/features/scrims/actions/scrims.server.ts
+++ b/app/features/scrims/actions/scrims.server.ts
@@ -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({
- 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({
- msg: "Selected time must be one of the available options",
- field: "at",
- });
+ return {
+ fieldErrors: {
+ at: "Selected time must be one of the available options",
+ },
+ };
}
}
diff --git a/app/features/scrims/routes/scrims.new.tsx b/app/features/scrims/routes/scrims.new.tsx
index 1056859f6..b00b45f87 100644
--- a/app/features/scrims/routes/scrims.new.tsx
+++ b/app/features/scrims/routes/scrims.new.tsx
@@ -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() {
-
+
@@ -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 (
-
-
- setValue("mapsTournamentId", tournament?.id ?? null)
- }
- />
-
- );
+ return ;
}
diff --git a/app/features/scrims/scrims-schemas.ts b/app/features/scrims/scrims-schemas.ts
index 257a297b6..a52a7af51 100644
--- a/app/features/scrims/scrims-schemas.ts
+++ b/app/features/scrims/scrims-schemas.ts
@@ -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) {
diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
index a488fee9a..e5622b6e0 100644
--- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx
+++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx
@@ -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 : (
({
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 ? (
-
+
) : (
{
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;
diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts
index e02f1046d..13de87791 100644
--- a/app/features/sendouq/actions/q.server.ts
+++ b/app/features/sendouq/actions/q.server.ts
@@ -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,
});
diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css
index dbf09540d..d964e50b5 100644
--- a/app/features/sendouq/components/GroupCard.module.css
+++ b/app/features/sendouq/components/GroupCard.module.css
@@ -81,10 +81,6 @@
height: 24px;
}
-.noteTextarea {
- height: 4rem !important;
-}
-
.futureMatchMode {
border-radius: 100%;
background-color: var(--color-bg);
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index 136eac151..7d1c23ab3 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -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 (
setEditing(false)} />
@@ -408,31 +406,18 @@ function AddPrivateNoteForm({
note?: string | null;
stopEditing: () => void;
}) {
- const fetcher = useFetcher();
- const textareaRef = React.useRef(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 (
-
-
+ }
+ onSuccess={stopEditing}
+ >
+ {({ FormField }) => }
+
);
}
diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts
index 862707d5a..1b64358db 100644
--- a/app/features/sendouq/q-constants.ts
+++ b/app/features/sendouq/q-constants.ts
@@ -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;
diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts
index 7781b2963..141918be2 100644
--- a/app/features/sendouq/q-schemas.server.ts
+++ b/app/features/sendouq/q-schemas.server.ts
@@ -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({
diff --git a/app/features/sendouq/q-schemas.ts b/app/features/sendouq/q-schemas.ts
new file mode 100644
index 000000000..a4dc987f7
--- /dev/null
+++ b/app/features/sendouq/q-schemas.ts
@@ -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,
+});
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts
index 12f6ef697..2fcf0fb95 100644
--- a/app/features/settings/actions/settings.server.ts
+++ b/app/features/settings/actions/settings.server.ts
@@ -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)) {
diff --git a/app/features/settings/components/LocaleTab.tsx b/app/features/settings/components/LocaleTab.tsx
index 482f056e4..5d1a902db 100644
--- a/app/features/settings/components/LocaleTab.tsx
+++ b/app/features/settings/components/LocaleTab.tsx
@@ -18,7 +18,7 @@ export function LocaleTab() {
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
- autoSubmit
+ mode="autoSubmit"
revalidateRoot
fullWidth
>
diff --git a/app/features/settings/components/PreferencesTab.tsx b/app/features/settings/components/PreferencesTab.tsx
index 9e76aa89c..f35d64c71 100644
--- a/app/features/settings/components/PreferencesTab.tsx
+++ b/app/features/settings/components/PreferencesTab.tsx
@@ -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
>
diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts
index 538a8380a..c4106f9e6 100644
--- a/app/features/settings/settings-schemas.ts
+++ b/app/features/settings/settings-schemas.ts
@@ -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({
diff --git a/app/features/sidebar/core/sidebar.server.ts b/app/features/sidebar/core/sidebar.server.ts
index 4aa690c95..bdd82b1f9 100644
--- a/app/features/sidebar/core/sidebar.server.ts
+++ b/app/features/sidebar/core/sidebar.server.ts
@@ -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();
@@ -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
>[number];
-function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
+function resolveFriends(
+ friendsWithActivity: FriendWithActivity[],
+ streamedSendouQMatches: ReadonlyMap,
+) {
+ 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,
};
}
diff --git a/app/features/team/team-schemas.ts b/app/features/team/team-schemas.ts
index 8daf9caea..6864d1dac 100644
--- a/app/features/team/team-schemas.ts
+++ b/app/features/team/team-schemas.ts
@@ -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,
diff --git a/app/features/tournament-admin/tournament-admin-staff-schemas.ts b/app/features/tournament-admin/tournament-admin-staff-schemas.ts
index 82d3ae1d0..aa9992bbc 100644
--- a/app/features/tournament-admin/tournament-admin-staff-schemas.ts
+++ b/app/features/tournament-admin/tournament-admin-staff-schemas.ts
@@ -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",
}),
diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts
index 2f93d1d3a..e10a1e687 100644
--- a/app/features/tournament-bracket/core/Progression.test.ts
+++ b/app/features/tournament-bracket/core/Progression.test.ts
@@ -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", () => {
diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts
index 2cde3fdc9..e5817e662 100644
--- a/app/features/tournament-bracket/core/Progression.ts
+++ b/app/features/tournament-bracket/core/Progression.ts
@@ -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,
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index 5c67c8678..8ad8fed8e 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -1049,6 +1049,7 @@ export class Tournament {
type: "MATCH",
matchId: match.id,
opponent: otherTeam.name,
+ opponentId: otherTeam.id,
} as const;
}
diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts
index a28ddbb7c..cfe53ed5c 100644
--- a/app/features/tournament-bracket/core/tests/test-utils.ts
+++ b/app/features/tournament-bracket/core/tests/test-utils.ts
@@ -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;
diff --git a/app/features/tournament-lfg/actions/to.$id.looking.server.ts b/app/features/tournament-lfg/actions/to.$id.looking.server.ts
index 543834af1..715918257 100644
--- a/app/features/tournament-lfg/actions/to.$id.looking.server.ts
+++ b/app/features/tournament-lfg/actions/to.$id.looking.server.ts
@@ -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(
diff --git a/app/features/tournament-match/components/TournamentMatchBanner.tsx b/app/features/tournament-match/components/TournamentMatchBanner.tsx
index 98b6cfd86..5129fdba0 100644
--- a/app/features/tournament-match/components/TournamentMatchBanner.tsx
+++ b/app/features/tournament-match/components/TournamentMatchBanner.tsx
@@ -211,17 +211,19 @@ export function TournamentMatchBanner({
/>
) : null}
-
+ {data.matchIsOver ? null : (
+
+ )}
);
}
@@ -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 ? (
-
+
) : (
)}
@@ -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,
diff --git a/app/features/tournament-organization/actions/org.$slug.edit.server.ts b/app/features/tournament-organization/actions/org.$slug.edit.server.ts
index 212e5ac30..31c02f52a 100644
--- a/app/features/tournament-organization/actions/org.$slug.edit.server.ts
+++ b/app/features/tournament-organization/actions/org.$slug.edit.server.ts
@@ -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({
- 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);
diff --git a/app/features/tournament-organization/actions/org.$slug.server.ts b/app/features/tournament-organization/actions/org.$slug.server.ts
index f6bf2ddb9..a235d9550 100644
--- a/app/features/tournament-organization/actions/org.$slug.server.ts
+++ b/app/features/tournament-organization/actions/org.$slug.server.ts
@@ -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");
diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx
index dd3fc3d8a..fef94a9fe 100644
--- a/app/features/tournament-organization/routes/org.$slug.tsx
+++ b/app/features/tournament-organization/routes/org.$slug.tsx
@@ -285,7 +285,7 @@ function AdminControls() {
defaultValues={{
isEstablished: Boolean(data.organization.isEstablished),
}}
- autoSubmit
+ mode="autoSubmit"
>
{({ FormField }) => }
diff --git a/app/features/tournament-organization/tournament-organization-schemas.ts b/app/features/tournament-organization/tournament-organization-schemas.ts
index ce543bca4..b1bd6d840 100644
--- a/app/features/tournament-organization/tournament-organization-schemas.ts
+++ b/app/features/tournament-organization/tournament-organization-schemas.ts
@@ -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,
diff --git a/app/features/tournament/components/TournamentHeader.tsx b/app/features/tournament/components/TournamentHeader.tsx
index 4bbffe4a8..cde255ed2 100644
--- a/app/features/tournament/components/TournamentHeader.tsx
+++ b/app/features/tournament/components/TournamentHeader.tsx
@@ -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}
-
+
);
}
@@ -167,43 +169,3 @@ function OrganizerLink({ tournament }: { tournament: Tournament }) {
);
}
-
-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 (
- }
- onPress={handleShare}
- aria-label={t("common:actions.share")}
- />
- );
- }
-
- return (
- }
- aria-label={t("common:actions.share")}
- />
- }
- />
- );
-}
diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts
index 7c19766e6..a13eee60f 100644
--- a/app/features/tournament/core/Standings.test.ts
+++ b/app/features/tournament/core/Standings.test.ts
@@ -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;
diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts
index 44155254a..9207ae067 100644
--- a/app/features/tournament/core/Standings.ts
+++ b/app/features/tournament/core/Standings.ts
@@ -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();
+
+ 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 },
>({
diff --git a/app/features/user-card/components/AddPrivateNoteDialog.tsx b/app/features/user-card/components/AddPrivateNoteDialog.tsx
index 0eef1a194..a0c018149 100644
--- a/app/features/user-card/components/AddPrivateNoteDialog.tsx
+++ b/app/features/user-card/components/AddPrivateNoteDialog.tsx
@@ -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;
/**
* 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 (
-
-
-
-
-
- {t("common:actions.save")}
-
-
-
+ {({ FormField }) => (
+ <>
+
+
+ >
+ )}
+
);
}
-
-function Sentiment({
- initialValue,
-}: {
- initialValue?: Tables["PrivateUserNote"]["sentiment"];
-}) {
- const { t } = useTranslation(["q"]);
- const [sentiment, setSentiment] = React.useState<
- Tables["PrivateUserNote"]["sentiment"]
- >(initialValue ?? "NEUTRAL");
-
- return (
-
-
{t("q:privateNote.sentiment.header")}
-
-
- {(["POSITIVE", "NEUTRAL", "NEGATIVE"] as const).map(
- (sentimentRadio) => {
- return (
-
-
setSentiment(sentimentRadio)}
- />
-
-
- {t(`q:privateNote.sentiment.${sentimentRadio}`)}
-
-
- );
- },
- )}
-
-
{t("q:privateNote.sentiment.info")}
-
- );
-}
-
-function Textarea({ initialValue }: { initialValue?: string | null }) {
- const { t } = useTranslation(["q"]);
- const [value, setValue] = React.useState(initialValue ?? "");
-
- return (
-
-
- {t("q:privateNote.comment.header")}
-
-
- );
-}
diff --git a/app/features/user-card/routes/user-card.$id.note.ts b/app/features/user-card/routes/user-card.$id.note.ts
index d9be85bc9..cdab5dcf8 100644
--- a/app/features/user-card/routes/user-card.$id.note.ts
+++ b/app/features/user-card/routes/user-card.$id.note.ts
@@ -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;
};
diff --git a/app/features/user-card/user-card-schemas.ts b/app/features/user-card/user-card-schemas.ts
index 224e0e878..deff6d26e 100644
--- a/app/features/user-card/user-card-schemas.ts
+++ b/app/features/user-card/user-card-schemas.ts
@@ -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"),
}),
diff --git a/app/features/user-page/actions/u.$identifier.admin.server.ts b/app/features/user-page/actions/u.$identifier.admin.server.ts
index b12f6464f..c2d1ee192 100644
--- a/app/features/user-page/actions/u.$identifier.admin.server.ts
+++ b/app/features/user-page/actions/u.$identifier.admin.server.ts
@@ -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!),
);
diff --git a/app/features/user-page/components/WidgetSettingsForm.tsx b/app/features/user-page/components/WidgetSettingsForm.tsx
index e11ab42f8..34f52b415 100644
--- a/app/features/user-page/components/WidgetSettingsForm.tsx
+++ b/app/features/user-page/components/WidgetSettingsForm.tsx
@@ -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({
@@ -91,13 +90,7 @@ function WidgetFormFields({ widgetId }: { widgetId: string }) {
case "links":
return ;
case "tier-list":
- return (
-
- {(props: CustomFieldRenderProps) => (
- )} />
- )}
-
- );
+ return ;
case "game-badges":
return (
@@ -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 (
-
- {t("widgets.forms.controller")}
-
- handleControllerChange(
- e.target.value as (typeof CONTROLLERS)[number],
- )
- }
- className={clsx(styles.sensSelect)}
- >
- {CONTROLLERS.map((ctrl) => (
-
- {t(`user:controllers.${ctrl}`)}
-
- ))}
-
-
+
@@ -227,41 +193,3 @@ function SensFields() {
);
}
-
-function TierListField({ value, onChange }: CustomFieldRenderProps
) {
- const { t } = useTranslation(["user"]);
-
- const handleChange = (e: React.ChangeEvent) => {
- 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 (
-
-
- {t("widgets.forms.tierListUrl")}
-
-
-
- );
-}
diff --git a/app/features/user-page/core/widgets/widget-form-schemas.ts b/app/features/user-page/core/widgets/widget-form-schemas.ts
index af149a2e4..cfab696fe 100644
--- a/app/features/user-page/core/widgets/widget-form-schemas.ts
+++ b/app/features/user-page/core/widgets/widget-form-schemas.ts
@@ -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> = {
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;
+ }
+}
diff --git a/app/features/user-page/user-page-schemas.ts b/app/features/user-page/user-page-schemas.ts
index 58821fc92..89a190d0a 100644
--- a/app/features/user-page/user-page-schemas.ts
+++ b/app/features/user-page/user-page-schemas.ts
@@ -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,
}),
diff --git a/app/features/user-report/user-report-schemas.ts b/app/features/user-report/user-report-schemas.ts
index eaab7fa0c..7fa88895a 100644
--- a/app/features/user-report/user-report-schemas.ts
+++ b/app/features/user-report/user-report-schemas.ts
@@ -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,
}),
diff --git a/app/features/vods/routes/vods.new.browser.test.tsx b/app/features/vods/routes/vods.new.browser.test.tsx
index 2eb8f9d91..d20b16d32 100644
--- a/app/features/vods/routes/vods.new.browser.test.tsx
+++ b/app/features/vods/routes/vods.new.browser.test.tsx
@@ -80,15 +80,11 @@ function renderForm(options?: {
schema={vodFormBaseSchema}
defaultValues={createDefaultValues(options?.defaultValues)}
>
- {({ names }) => (
- <>
- {Object.keys(names)
- .filter((name) => name !== "pov")
- .map((name) => (
-
- ))}
- >
- )}
+ {Object.keys(vodFormBaseSchema.shape)
+ .filter((name) => name !== "pov")
+ .map((name) => (
+
+ ))}
),
},
diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx
index 1a87a48ec..b126c1378 100644
--- a/app/features/vods/routes/vods.new.tsx
+++ b/app/features/vods/routes/vods.new.tsx
@@ -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>;
+ 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>).map((match) => ({
...match,
weaponsTeamOne: [],
weaponsTeamTwo: [],
- }));
- setValue("matches", clearedMatches);
- }
+ })),
+ );
};
- return (
-
- {({ name, error, value }: CustomFieldRenderProps) => (
-
- handleTeamSizeChange(e.target.value)}
- >
- 1v1
- 2v2
- 3v3
- 4v4
-
-
- )}
-
- );
+ return ;
}
function PovFormField({ FormField }: { FormField: VodFormFieldComponent }) {
@@ -420,33 +394,19 @@ function MatchFieldsetContent({
-
- {(props: CustomFieldRenderProps) => (
-
+
+ {currentTime ? (
+ setItemField("startsAt", currentTime)}
+ className="mt-2"
>
- setItemField("startsAt", e.target.value)}
- placeholder="10:22"
- />
- {currentTime ? (
- setItemField("startsAt", currentTime)}
- className="mt-2"
- >
- {t("vods:forms.action.setAsCurrent", { time: currentTime })}
-
- ) : null}
-
- )}
-
+ {t("vods:forms.action.setAsCurrent", { time: currentTime })}
+
+ ) : null}
+
diff --git a/app/features/vods/vods-schemas.ts b/app/features/vods/vods-schemas.ts
index cd79e2919..1659c5ffd 100644
--- a/app/features/vods/vods-schemas.ts
+++ b/app/features/vods/vods-schemas.ts
@@ -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",
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx
index 83bb33bc7..16c54cbef 100644
--- a/app/form/FormField.tsx
+++ b/app/form/FormField.tsx
@@ -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({
void}
/>
@@ -247,6 +285,7 @@ export function FormField({
({
value: opt.value,
label: opt.label,
@@ -262,6 +301,7 @@ export function FormField({
void}
/>
@@ -273,6 +313,7 @@ export function FormField({
void}
/>
@@ -288,6 +329,7 @@ export function FormField({
void}
@@ -300,6 +342,7 @@ export function FormField({
void}
/>
@@ -311,6 +354,7 @@ export function FormField({
void}
@@ -323,6 +367,7 @@ export function FormField({
void
@@ -336,6 +381,7 @@ export function FormField({
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({
void}
isObjectArray={isObjectArray}
@@ -433,7 +478,12 @@ export function FormField({
}
return (
-
+
);
}}
/>
@@ -441,7 +491,9 @@ export function FormField({
}
if (formField.type === "fieldset") {
- return ;
+ return (
+
+ );
}
if (formField.type === "user-search") {
@@ -450,6 +502,7 @@ export function FormField({
void}
onUserSelected={userOptions?.onUserSelected}
@@ -465,6 +518,7 @@ export function FormField({
void}
pastOnly={tournamentOptions?.pastOnly}
@@ -478,6 +532,7 @@ export function FormField({
void}
onTeamSelected={teamOptions?.onTeamSelected}
initialTeam={teamOptions?.initialTeam}
@@ -493,6 +548,7 @@ export function FormField({
void}
options={options as BadgeOption[]}
@@ -506,6 +562,7 @@ export function FormField({
void}
/>
@@ -517,6 +574,7 @@ export function FormField({
void}
/>
diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx
index fa3bb6abd..df1710735 100644
--- a/app/form/SendouForm.browser.test.tsx
+++ b/app/form/SendouForm.browser.test.tsx
@@ -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;
title?: string;
submitButtonText?: string;
- autoSubmit?: boolean;
+ mode?: "autoSubmit";
},
) {
const props: ComponentProps> = {
@@ -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) => (
))}
>
@@ -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 }) => }
+
),
},
@@ -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: (
- {({ names }) => }
+
),
},
@@ -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: (
- {({ names }) => }
+
),
},
@@ -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: (
- {({ names }) => (
- <>
-
-
- >
- )}
+
+
),
},
@@ -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 }),
});
diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx
index a7ba62125..f6246eea8 100644
--- a/app/form/SendouForm.tsx
+++ b/app/form/SendouForm.tsx
@@ -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 {
setClientError: (name: string, error: string | undefined) => void;
clearServerError: (name: string) => void;
onFieldChange?: (name: string, newValue: unknown) => void;
- hideRequiredIndicator: boolean;
readOnly: boolean;
values: Record;
setValue: (name: string, value: unknown) => void;
@@ -56,9 +57,12 @@ export interface FormContextValue {
interface FormStore {
values: Record;
clientErrors: Partial>;
+ /** Has the user edited any field since mount / the last successful submit? */
+ dirty: boolean;
subscribe: (listener: () => void) => () => void;
setValues: (values: Record) => void;
setClientErrors: (errors: Partial>) => void;
+ setDirty: (dirty: boolean) => void;
}
type FormFieldContextValue = Omit<
@@ -72,27 +76,27 @@ const FormContext = React.createContext(null);
export const EMPTY_FORM_STORE = createFormStore({}, {});
-type FormNames = {
- [K in keyof T]: K;
-};
-
export interface FormRenderProps {
- names: FormNames;
FormField: TypedFormFieldComponent;
}
+export type FormMode = "submit" | "autoSubmit" | "client";
+
type BaseFormProps = {
children: React.ReactNode | ((props: FormRenderProps) => React.ReactNode);
schema: z.ZodObject;
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 = {
* 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>) => void;
secondarySubmit?: React.ReactNode;
/**
* Called once after a server submission completes successfully (the action
@@ -121,22 +118,43 @@ type BaseFormProps = {
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 `
diff --git a/app/form/fields/UserSearchFormField.tsx b/app/form/fields/UserSearchFormField.tsx
index f30b9d410..a9bf1fb18 100644
--- a/app/form/fields/UserSearchFormField.tsx
+++ b/app/form/fields/UserSearchFormField.tsx
@@ -7,6 +7,7 @@ type UserSearchFormFieldProps = FormFieldProps<"user-search"> &
UserSearchFieldOptions & {
value: number | null;
onChange: (value: number | null) => void;
+ disabled?: boolean;
};
export function UserSearchFormField({
@@ -19,6 +20,7 @@ export function UserSearchFormField({
onChange,
onUserSelected,
onBlur,
+ disabled,
}: UserSearchFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({
label,
@@ -36,6 +38,7 @@ export function UserSearchFormField({
onBlur={() => onBlur?.()}
label={translatedLabel}
isRequired={required}
+ isDisabled={disabled}
/>
diff --git a/app/form/fields/WeaponPoolFormField.tsx b/app/form/fields/WeaponPoolFormField.tsx
index 515be77e4..62a0556a7 100644
--- a/app/form/fields/WeaponPoolFormField.tsx
+++ b/app/form/fields/WeaponPoolFormField.tsx
@@ -36,6 +36,7 @@ export type WeaponPoolItem = {
type WeaponPoolFormFieldProps = FormFieldProps<"weapon-pool"> & {
value: WeaponPoolItem[];
onChange: (value: WeaponPoolItem[]) => void;
+ disabled?: boolean;
};
export function WeaponPoolFormField({
@@ -50,6 +51,7 @@ export function WeaponPoolFormField({
value,
onChange,
onBlur,
+ disabled,
}: WeaponPoolFormFieldProps) {
const { t } = useTranslation(["forms"]);
const id = React.useId();
@@ -116,13 +118,14 @@ export function WeaponPoolFormField({
const weaponList = (
{value.map((weapon) =>
- disableSorting ? (
+ disableSorting || disabled ? (
) : (
void;
onRemove: (id: MainWeaponId) => void;
+ disabled?: boolean;
}) {
const { t } = useTranslation(["weapons"]);
@@ -222,6 +227,7 @@ function StaticWeaponItem({
}
aria-label="Toggle favorite"
onPress={() => onToggleFavorite(weapon.id)}
+ isDisabled={disabled}
/>
) : null}
}
aria-label="Delete"
onPress={() => onRemove(weapon.id)}
+ isDisabled={disabled}
/>
diff --git a/app/form/fields/WeaponSelectFormField.tsx b/app/form/fields/WeaponSelectFormField.tsx
index 9ecb4f08d..625a8029b 100644
--- a/app/form/fields/WeaponSelectFormField.tsx
+++ b/app/form/fields/WeaponSelectFormField.tsx
@@ -7,6 +7,7 @@ import styles from "./WeaponSelectFormField.module.css";
type WeaponSelectFormFieldProps = FormFieldProps<"weapon-select"> & {
value: MainWeaponId | null;
onChange: (value: MainWeaponId | null) => void;
+ disabled?: boolean;
};
export function WeaponSelectFormField({
@@ -18,6 +19,7 @@ export function WeaponSelectFormField({
value,
onChange,
onBlur,
+ disabled,
}: WeaponSelectFormFieldProps) {
const { translatedLabel } = useTranslatedTexts({ label });
@@ -32,6 +34,7 @@ export function WeaponSelectFormField({
}}
isRequired={required}
clearable={!required}
+ isDisabled={disabled}
/>
diff --git a/app/form/index.ts b/app/form/index.ts
index fc7c2cb25..91f3f8a5b 100644
--- a/app/form/index.ts
+++ b/app/form/index.ts
@@ -1,4 +1,10 @@
// Form system exports
-export { SendouForm } from "./SendouForm";
+
// Types
+export type {
+ FormDefaultValues,
+ FormMode,
+ FormRenderProps,
+} from "./SendouForm";
+export { SendouForm } from "./SendouForm";
export type { ArrayItemRenderContext, CustomFieldRenderProps } from "./types";
diff --git a/app/form/parse.server.ts b/app/form/parse.server.ts
index 008cee3a6..334249e05 100644
--- a/app/form/parse.server.ts
+++ b/app/form/parse.server.ts
@@ -10,6 +10,13 @@ export type ParseResult
=
| { success: true; data: T }
| { success: false; fieldErrors: Record };
+/**
+ * Ceiling for a request body, in bytes. Sized to fit a form with a couple of `image()` fields (each
+ * capped at ~3M base64 characters) plus its other fields. Forms that legitimately submit more (e.g.
+ * art) pass their own `maxBodyBytes`.
+ */
+const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
+
/**
* Maps a {@link z.ZodError} to field-level errors keyed by form field name
* (e.g. `members[0].userId`), keeping the first error per field.
@@ -34,14 +41,14 @@ function fieldErrorsFromZodError(error: z.ZodError): Record {
export async function parseFormData({
request,
schema,
+ maxBodyBytes = DEFAULT_MAX_BODY_BYTES,
}: {
request: Request;
schema: T;
+ /** Overrides {@link DEFAULT_MAX_BODY_BYTES} for forms that legitimately submit a bigger body. */
+ maxBodyBytes?: number;
}): Promise>> {
- const data =
- request.headers.get("Content-Type") === "application/json"
- ? await request.json()
- : formDataToObject(await request.formData());
+ const data = await requestBodyToObject(request, maxBodyBytes);
const result = await schema.safeParseAsync(data);
@@ -116,3 +123,52 @@ function imageFields(
return [...fields].map(([key, autoValidate]) => ({ key, autoValidate }));
}
+
+/**
+ * Reads the request body into the plain object the schema parses, refusing anything over
+ * `maxBytes`. `Content-Length` is checked up front so an oversized body is rejected before a byte
+ * of it is read.
+ */
+async function requestBodyToObject(request: Request, maxBytes: number) {
+ if (Number(request.headers.get("Content-Length")) > maxBytes) {
+ throw payloadTooLarge();
+ }
+
+ if (request.headers.get("Content-Type") === "application/json") {
+ return JSON.parse(await readBodyText(request, maxBytes));
+ }
+
+ return formDataToObject(await request.formData());
+}
+
+/**
+ * Reads the body as text, aborting the stream as soon as `maxBytes` is exceeded. The running total
+ * is enforced (rather than trusting `Content-Length`) so a chunked body that omits or understates
+ * the header can't be buffered in full either.
+ */
+async function readBodyText(request: Request, maxBytes: number) {
+ const reader = request.body?.getReader();
+ if (!reader) return "";
+
+ const decoder = new TextDecoder();
+ let bytesRead = 0;
+ let text = "";
+
+ let chunk = await reader.read();
+ while (!chunk.done) {
+ bytesRead += chunk.value.byteLength;
+ if (bytesRead > maxBytes) {
+ await reader.cancel();
+ throw payloadTooLarge();
+ }
+
+ text += decoder.decode(chunk.value, { stream: true });
+ chunk = await reader.read();
+ }
+
+ return text + decoder.decode();
+}
+
+function payloadTooLarge() {
+ return new Response(null, { status: 413 });
+}
diff --git a/app/form/types.ts b/app/form/types.ts
index b976da28c..ca2f1296d 100644
--- a/app/form/types.ts
+++ b/app/form/types.ts
@@ -1,7 +1,6 @@
import type { z } from "zod";
import type { TeamSearchResult } from "~/components/elements/TeamSearch";
import type { UserSearchResult } from "~/components/elements/UserSearch";
-import type { ModeShort } from "~/modules/in-game-lists/types";
import type forms from "../../locales/en/forms.json";
import type { ImageFieldDimensions } from "./image-field";
@@ -14,17 +13,21 @@ interface FormFieldBase {
initialValue: unknown;
}
-type FormFieldConstant = Omit<
+/** A field that never renders a control. Its value is seeded from the schema's `initialValue` or the form's `defaultValues`. */
+type FormFieldHidden = Omit<
FormFieldBase,
"label" | "bottomText"
-> & {
- value: string | number | null;
-};
+>;
interface FormFieldText extends FormFieldBase {
minLength?: number;
maxLength: number;
toLowerCase?: boolean;
+ /**
+ * Normalizes what the user types before it is stored, e.g. reducing a pasted
+ * full URL down to the part the field actually holds.
+ */
+ transformValue?: (value: string) => string;
leftAddon?: string;
placeholder?: string;
required: boolean;
@@ -57,6 +60,7 @@ interface FormFieldItem {
}
interface FormFieldItemWithImage extends FormFieldItem {
+ /** Full image url (including the file extension) shown next to the item's label. */
imgSrc?: string;
}
@@ -116,13 +120,6 @@ interface FormFieldWeaponPool extends FormFieldBase {
disableAltSkinDuplicates?: boolean;
}
-interface FormFieldMapPool extends FormFieldBase {
- modes?: ModeShort[];
- minCount?: number;
- maxCount?: number;
- disableBannedMaps?: boolean;
-}
-
interface FormFieldImage extends FormFieldBase {
dimensions?: ImageFieldDimensions;
/** Validate uploaded images immediately, bypassing the moderator queue (e.g. trusted org logos). */
@@ -199,11 +196,8 @@ export type FormField =
| FormFieldDatetime<"datetime">
| FormFieldDatetime<"date">
| FormFieldWeaponPool<"weapon-pool">
- | FormFieldMapPool<"map-pool">
- | FormFieldBase<"theme">
| FormFieldImage<"image">
- | FormFieldConstant<"string-constant">
- | FormFieldConstant<"id-constant">
+ | FormFieldHidden<"hidden">
| FormFieldArray<"array", z.ZodType>
| FormFieldTimeRange<"time-range">
| FormFieldFieldset<"fieldset", z.ZodRawShape>
@@ -260,6 +254,8 @@ export type CustomFieldRenderProps = {
error: string | undefined;
value: TValue;
onChange: (value: TValue) => void;
+ /** True when the field's `disabled` prop is set or the whole form is `readOnly`. */
+ disabled?: boolean;
};
/** Non-generic version for internal use to avoid excessive type instantiation */
@@ -268,6 +264,7 @@ type FormFieldChildrenProps = {
error: string | undefined;
value: unknown;
onChange: (value: unknown) => void;
+ disabled?: boolean;
};
/** Props for a typed FormField based on field name and schema */
@@ -278,8 +275,11 @@ export type TypedFormFieldProps<
name: TName;
label?: string;
disabled?: boolean;
+ /** Focuses the field on mount. Only `text-field` and `text-area` support it. */
+ autoFocus?: boolean;
maxCount?: number;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
+ onValueChange?: (newValue: unknown) => void;
children?:
| ((props: FormFieldChildrenProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
@@ -295,8 +295,11 @@ export type FlexibleFormFieldProps = {
name: NestedPath;
label?: string;
disabled?: boolean;
+ /** Focuses the field on mount. Only `text-field` and `text-area` support it. */
+ autoFocus?: boolean;
maxCount?: number;
canRemoveItem?: (itemValue: unknown, index: number) => boolean;
+ onValueChange?: (newValue: unknown) => void;
children?:
| ((props: FormFieldChildrenProps) => React.ReactNode)
| ((props: ArrayItemRenderContext) => React.ReactNode);
diff --git a/app/form/utils.ts b/app/form/utils.ts
index 74380f06c..8692fa3da 100644
--- a/app/form/utils.ts
+++ b/app/form/utils.ts
@@ -2,7 +2,7 @@ import type { z } from "zod";
import { getFormFieldMetadata } from "./fields";
import type { FormField } from "./types";
-function infoMessageId(fieldId: string) {
+export function infoMessageId(fieldId: string) {
return `${fieldId}-info`;
}
@@ -245,21 +245,38 @@ export function validateField(
return issue.message;
}
+/**
+ * Accessibility attributes for a form control. Ids are derived from the field
+ * `name` because the error/info messages render with name-based ids
+ * (`errorMessageId`/`infoMessageId`) — a `useId()` value would point at
+ * elements that don't exist. The error id is also included in
+ * `aria-describedby` since `aria-errormessage` support in screen readers is
+ * still inconsistent.
+ */
export function ariaAttributes({
- id,
+ name,
error,
bottomText,
required,
}: {
- id: string;
+ name?: string;
error?: string;
bottomText?: string;
required?: boolean;
}) {
+ const describedBy = name
+ ? [
+ error ? errorMessageId(name) : undefined,
+ bottomText ? infoMessageId(name) : undefined,
+ ]
+ .filter((id) => id !== undefined)
+ .join(" ")
+ : "";
+
return {
"aria-invalid": error ? ("true" as const) : undefined,
- "aria-describedby": bottomText ? infoMessageId(id) : undefined,
- "aria-errormessage": error ? errorMessageId(id) : undefined,
+ "aria-describedby": describedBy !== "" ? describedBy : undefined,
+ "aria-errormessage": error && name ? errorMessageId(name) : undefined,
"aria-required": required ? ("true" as const) : undefined,
};
}
diff --git a/app/root.tsx b/app/root.tsx
index 7e89c14cf..5556911cd 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -52,6 +52,7 @@ import {
useTheme,
} from "./features/theme/core/provider";
import { getThemeSession } from "./features/theme/core/theme-session.server";
+import { UnsavedChangesGuard } from "./form/UnsavedChangesGuard";
import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference";
import { useHydrated } from "./hooks/useHydrated";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
@@ -236,6 +237,7 @@ function Document({
+
{children}
diff --git a/app/utils/remix.server.ts b/app/utils/remix.server.ts
index 4513681ed..fec2ff4cf 100644
--- a/app/utils/remix.server.ts
+++ b/app/utils/remix.server.ts
@@ -1,5 +1,3 @@
-import type { FileUpload } from "@remix-run/form-data-parser";
-import { parseFormData as parseMultipartFormData } from "@remix-run/form-data-parser";
import type { Namespace, TFunction } from "i18next";
import type { Ok, Result } from "neverthrow";
import type { Params, UIMatch } from "react-router";
@@ -122,28 +120,6 @@ export async function parseRequestPayload({
}
}
-/**
- * @deprecated - use parseFormData from /app/form/parse.server.ts (with SendouForm) or parseRequestPayload (without SendouForm)
- *
- * Parse formData with the given schema. Throws a request to show an error toast if it fails.
- */
-export async function parseFormData({
- formData,
- schema,
-}: {
- formData: FormData;
- schema: T;
-}): Promise> {
- const formDataObj = formDataToObject(formData);
- try {
- return await schema.parseAsync(formDataObj);
- } catch (e) {
- logger.error("Error parsing form data", e);
-
- throw errorToastRedirect("Validation failed");
- }
-}
-
/** Parse params with the given schema. Throws HTTP 404 response if fails. */
export function parseParams({
params,
@@ -176,33 +152,6 @@ export async function parseBody({
return parsed.data;
}
-export async function safeParseRequestFormData({
- request,
- schema,
-}: {
- request: Request;
- schema: T;
-}): Promise<
- { success: true; data: z.infer } | { success: false; errors: string[] }
-> {
- const parsed = schema.safeParse(formDataToObject(await request.formData()));
-
- // this implementation is somewhat redundant but it's the only way I got types to work nice
- if (!parsed.success) {
- return {
- success: false,
- errors: parsed.error.issues.map(
- (issue: { message: string }) => issue.message,
- ),
- };
- }
-
- return {
- success: true,
- data: parsed.data,
- };
-}
-
export function formDataToObject(formData: FormData) {
const result: Record = {};
@@ -275,18 +224,6 @@ export function successToastWithRedirect({
return redirect(`${url}?__success=${message}`);
}
-export type ActionError = { field: string; msg: string; isError: true };
-
-export function actionError({
- msg,
- field,
-}: {
- msg: string;
- field: (keyof z.infer & string) | `${keyof z.infer & string}.root`;
-}): ActionError {
- return { msg, field, isError: true };
-}
-
export type Breadcrumb =
| {
imgPath: string;
@@ -340,53 +277,3 @@ export function privatelyCachedJson(dataValue: T) {
headers: { "Cache-Control": "private, max-age=5" },
});
}
-
-const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;
-
-type FileUploadHandler = (
- fileUpload: FileUpload,
-) => Promise;
-type ParseFormDataOptions = { maxFileSize?: number };
-
-export function safeParseMultipartFormData(
- request: Request,
- uploadHandler?: FileUploadHandler,
-): Promise;
-export function safeParseMultipartFormData(
- request: Request,
- options?: ParseFormDataOptions,
- uploadHandler?: FileUploadHandler,
-): Promise;
-export async function safeParseMultipartFormData(
- request: Request,
- optionsOrHandler?: ParseFormDataOptions | FileUploadHandler,
- uploadHandler?: FileUploadHandler,
-): Promise {
- const maxFileSize =
- typeof optionsOrHandler === "object" && optionsOrHandler?.maxFileSize
- ? optionsOrHandler.maxFileSize
- : DEFAULT_MAX_FILE_SIZE_BYTES;
-
- try {
- if (typeof optionsOrHandler === "function") {
- return await parseMultipartFormData(request, optionsOrHandler);
- }
- return await parseMultipartFormData(
- request,
- optionsOrHandler,
- uploadHandler,
- );
- } catch (err) {
- if (
- err instanceof Error &&
- (err.name === "MaxFileSizeExceededError" ||
- (err.cause instanceof Error &&
- err.cause.name === "MaxFileSizeExceededError"))
- ) {
- throw errorToastRedirect(
- `File size exceeds maximum allowed size of ${maxFileSize / 1024 / 1024}MB`,
- );
- }
- throw err;
- }
-}
diff --git a/app/utils/zod.ts b/app/utils/zod.ts
index e8b7af5bd..386fe4f5c 100644
--- a/app/utils/zod.ts
+++ b/app/utils/zod.ts
@@ -1,6 +1,5 @@
import type { ZodType } from "zod";
import { z } from "zod";
-import type { DBBoolean } from "~/db/tables";
import {
abilities,
type abilitiesShort,
@@ -30,13 +29,6 @@ export const nonEmptyString = z.string().trim().min(1, {
message: "Required",
});
-export const dbBoolean = z.coerce
- .number()
- .int()
- .min(0)
- .max(1)
- .transform((value): DBBoolean => (value === 0 ? 0 : 1));
-
// matches #RGB and #RRGGBB only (no alpha) https://stackoverflow.com/a/1636354
const hexCodeWithoutAlphaRegex = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
export const hexCodeWithoutAlpha = z.string().regex(hexCodeWithoutAlphaRegex);
@@ -410,12 +402,6 @@ export function removeDuplicates(value: unknown) {
return Array.from(new Set(value));
}
-export function toArray(value: T | Array) {
- if (Array.isArray(value)) return value;
-
- return [value];
-}
-
export function emptyArrayToNull(value: unknown) {
if (Array.isArray(value) && value.length === 0) return null;
@@ -432,12 +418,6 @@ export function checkboxValueToBoolean(value: unknown) {
return value === "on";
}
-export function checkboxValueToDbBoolean(value: unknown): DBBoolean {
- if (checkboxValueToBoolean(value)) return 1;
-
- return 0;
-}
-
export const _action = (value: T) =>
z.preprocess(deduplicate, z.literal(value));
diff --git a/docs/dev/forms.md b/docs/dev/forms.md
index 6860cdf6b..a097570ac 100644
--- a/docs/dev/forms.md
+++ b/docs/dev/forms.md
@@ -15,7 +15,7 @@ This document describes the schema-based form system using `SendouForm`. Forms a
```ts
export const myFormSchema = z.object({
- name: textFieldRequired({
+ name: textField({
label: "labels.name",
maxLength: 100,
}),
@@ -34,10 +34,10 @@ export const myFormSchema = z.object({
| Builder | Description | Required Props |
|---------|-------------|----------------|
-| `textFieldRequired` | Required text input | `label`, `maxLength` |
+| `textField` | Required text input | `label`, `maxLength` |
| `textFieldOptional` | Optional text input | `maxLength` |
| `numberFieldOptional` | Optional number input | - |
-| `textAreaRequired` | Required multiline text | `label`, `maxLength` |
+| `textArea` | Required multiline text | `label`, `maxLength` |
| `textAreaOptional` | Optional multiline text | `maxLength` |
| `toggle` | Boolean switch | `label` |
| `select` | Required dropdown | `label`, `items` |
@@ -45,9 +45,9 @@ export const myFormSchema = z.object({
| `selectDynamicOptional` | Dropdown with runtime options | `label` |
| `radioGroup` | Radio button group | `label`, `items` |
| `checkboxGroup` | Multiple selection checkboxes | `label`, `items` |
-| `datetimeRequired` | Required date/time picker | `label` |
+| `datetime` | Required date/time picker | `label` |
| `datetimeOptional` | Optional date/time picker | `label` |
-| `dayMonthYearRequired` | Date picker (day only) | `label` |
+| `dayMonthYear` | Date picker (day only) | `label` |
| `dualSelectOptional` | Two linked dropdowns | `fields` |
| `timeRangeOptional` | Start/end time range | `label` |
| `weaponPool` | Weapon selection pool | `label`, `maxCount` |
@@ -93,7 +93,7 @@ Define action discriminators with `stringConstant`:
```ts
export const myFormSchema = z.object({
_action: stringConstant("CREATE_ITEM"),
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
```
@@ -104,7 +104,7 @@ Use `idConstant` for IDs that need default values:
```ts
export const editFormSchema = z.object({
itemId: idConstant(), // Requires defaultValues
- name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
+ name: textField({ label: "labels.name", maxLength: 100 }),
});
```
@@ -113,13 +113,13 @@ When `idConstant()` is called without a value, the schema requires `defaultValue
### Text Field Validation
```ts
-textFieldRequired({
+textField({
label: "labels.url",
maxLength: 200,
validate: "url", // Built-in URL validation
})
-textFieldRequired({
+textField({
label: "labels.custom",
maxLength: 100,
validate: {
@@ -128,7 +128,7 @@ textFieldRequired({
},
})
-textFieldRequired({
+textField({
label: "labels.pattern",
maxLength: 10,
regExp: {
@@ -141,7 +141,7 @@ textFieldRequired({
### DateTime Validation
```ts
-datetimeRequired({
+datetime({
label: "labels.date",
min: new Date(),
max: add(new Date(), { days: 30 }),
@@ -175,7 +175,7 @@ dualSelectOptional({
```ts
const itemSchema = z.object({
- name: textFieldRequired({ label: "labels.itemName", maxLength: 50 }),
+ name: textField({ label: "labels.itemName", maxLength: 50 }),
quantity: numberFieldOptional({ label: "labels.quantity" }),
});
@@ -194,7 +194,7 @@ export const formSchema = z.object({
Place field inside `z.union([])` to reuse across multiple schemas:
```ts
-const sharedNameField = textFieldRequired({
+const sharedNameField = textField({
label: "labels.name",
maxLength: 100,
});
@@ -256,10 +256,18 @@ const data = useLoaderData();
```
+### Form Modes
+
+The `mode` prop controls 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 `` element; every change is passed to `onApply` (required in this mode) and field errors are computed already on mount.
+
### Auto-Submit Forms
```tsx
-
+
{({ FormField }) => (
)}
@@ -268,6 +276,8 @@ const data = useLoaderData();
### Client-Side Only (onApply)
+Submitting hands the validated values to `onApply` instead of the server:
+
```tsx
();
```
+For applying every change immediately (no submit button), use `mode="client"` with `onApply`.
+
### Dynamic Select Options
For `selectDynamicOptional`, pass options via the `options` prop:
@@ -316,10 +328,12 @@ const badgeOptions = badges.map((b) => ({
pipeline and the `UnvalidatedUserSubmittedImage` admin-validation / supporter auto-validation
flow, while keeping `SendouForm`'s single-submit `application/json` model unchanged.
-> **Art upload is out of scope.** Art stays on its dedicated multipart route (`/art/new`): it
-> produces two derived assets (full + thumbnail), preserves aspect ratio, keeps the original
-> format, allows up to 5MB, and has its own `Art` table. Any future "large / aspect-preserving
-> / multi-derivative" upload should likewise stay off this field.
+> **Art upload is out of scope.** Art (`/art/new`) preserves aspect ratio, keeps the original
+> format, produces two derived assets (full + thumbnail) and stores them on its own `Art` table
+> rather than as a `UserSubmittedImage` id. It therefore uses a `customField` with its own
+> renderer (`ArtImageFormField`) and its own server resolver (`uploadArtImage`), while still
+> submitting as base64 within `SendouForm`'s single JSON submit. Any future "aspect-preserving /
+> multi-derivative" upload should likewise stay off this field and follow that pattern.
### Schema
@@ -492,7 +506,7 @@ When you need async validation (database checks, authorization), create a separa
```ts
import { z } from "zod";
-import { textFieldRequired, idConstantOptional } from "~/form/fields";
+import { textField, idConstantOptional } from "~/form/fields";
// Shared sync validation that can be extracted for reuse
function validateGearAllOrNone(data: {
@@ -515,7 +529,7 @@ export const gearAllOrNoneRefine = {
// Base schema with form field builders (for UI generation)
export const newBuildBaseSchema = z.object({
buildToEditId: idConstantOptional(),
- title: textFieldRequired({ label: "labels.buildTitle", maxLength: 50 }),
+ title: textField({ label: "labels.buildTitle", maxLength: 50 }),
// ... other fields
});
@@ -599,7 +613,7 @@ For complex validation involving multiple fields:
```ts
export const scrimsNewFormSchema = z
.object({
- at: datetimeRequired({ label: "labels.start" }),
+ at: datetime({ label: "labels.start" }),
maps: select({ label: "labels.maps", items: mapsItems }),
mapsTournamentId: customField({ initialValue: null }, id.nullable()),
})
@@ -720,7 +734,7 @@ import {
```ts
import { z } from "zod";
import {
- textFieldRequired,
+ textField,
textAreaOptional,
select,
toggle,
@@ -729,7 +743,7 @@ import {
export const createItemSchema = z.object({
_action: stringConstant("CREATE"),
- name: textFieldRequired({
+ name: textField({
label: "labels.itemName",
maxLength: 100,
}),
diff --git a/e2e/art.spec.ts b/e2e/art.spec.ts
index 731310b68..c627fcbb3 100644
--- a/e2e/art.spec.ts
+++ b/e2e/art.spec.ts
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
import { expect, impersonate, test } from "./helpers/playwright";
import { NewArtPage } from "./pages/art/new-art-page";
+import { UserArtPage } from "./pages/art/user-art-page";
import { ImageValidationPage } from "./pages/img-upload/image-validation-page";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -50,4 +51,34 @@ test.describe("Art", () => {
expect(box!.width).toBeGreaterThan(0);
expect(box!.height).toBeGreaterThan(0);
});
+
+ test("edits already uploaded art keeping its image", async ({
+ page,
+ factories,
+ }) => {
+ await factories.UserFactory.grant(NZAP_TEST_ID, { roles: ["ARTIST"] });
+ const art = await factories.ArtFactory.create({ authorId: NZAP_TEST_ID });
+
+ await impersonate(page, NZAP_TEST_ID);
+
+ const userArt = new UserArtPage(page);
+ await userArt.goto(NZAP_TEST_DISCORD_ID);
+ await userArt.editLink(art.id).click();
+
+ const newArt = new NewArtPage(page);
+
+ // the already uploaded image is shown but can't be swapped
+ // only rendering is asserted as the factory made art has no image file
+ await expect(newArt.locators.existingImage).toBeAttached();
+ await expect(newArt.locators.fileInput).toHaveCount(0);
+
+ await newArt.form.fill("description", "Squid drawing");
+ await newArt.save();
+
+ await expect(page).toHaveURL(/\/u\/.*\/art/);
+
+ // the saved description is loaded back into the form for editing
+ await newArt.goto(art.id);
+ await expect(newArt.locators.descriptionInput).toHaveValue("Squid drawing");
+ });
});
diff --git a/e2e/ban.spec.ts b/e2e/ban.spec.ts
index a296c7bb8..6dbac93e8 100644
--- a/e2e/ban.spec.ts
+++ b/e2e/ban.spec.ts
@@ -49,7 +49,7 @@ test.describe("User banning", () => {
const adminBan = new AdminBanPage(page);
await adminBan.goto();
await adminBan.banUser("N-ZAP", {
- until: startOfHour(setHours(addDays(new Date(), 1), 12)),
+ expiresAt: startOfHour(setHours(addDays(new Date(), 1), 12)),
reason: "Temporary ban",
});
diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts
index b66f6a7de..9fd2b4220 100644
--- a/e2e/calendar.spec.ts
+++ b/e2e/calendar.spec.ts
@@ -1,9 +1,13 @@
+import { subDays } from "date-fns";
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { dateToDatabaseTimestamp } from "~/utils/dates";
+import { calendarEventPage } from "~/utils/urls";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
+import { CalendarEventPage } from "./pages/calendar/calendar-event-page";
import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
+import { ReportWinnersPage } from "./pages/calendar/report-winners-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
import { TournamentInfoPage } from "./pages/tournament/tournament-info-page";
import { TournamentRulesPage } from "./pages/tournament/tournament-rules-page";
@@ -220,4 +224,49 @@ test.describe("Calendar", () => {
await expect(rules.modeImage(mode).first()).toBeVisible();
}
});
+
+ test("reports winners of a past event", async ({ page, factories }) => {
+ const event = await factories.CalendarEventFactory.create({
+ authorId: ADMIN_ID,
+ startTimes: [dateToDatabaseTimestamp(subDays(new Date(), 1))],
+ });
+
+ await impersonate(page);
+
+ const reportWinners = new ReportWinnersPage(page);
+ await reportWinners.goto(event.id);
+
+ await reportWinners.locators.participantCountInput.fill("50");
+ await reportWinners.locators.teamNameInput.fill("Team Olive");
+ await reportWinners.locators.placingInput.fill("1");
+
+ // a team without any players can't be reported
+ await reportWinners.locators.submitButton.click();
+ await expect(reportWinners.locators.emptyTeamError).toBeVisible();
+
+ await reportWinners.selectPlayer(1, "N-ZAP");
+ await reportWinners.fillPlayerAsText(2, "Player Without Account");
+
+ await reportWinners.submit();
+
+ await expect(page).toHaveURL(calendarEventPage(event.id));
+
+ const calendarEvent = new CalendarEventPage(page);
+ await expect(calendarEvent.resultRow("Team Olive")).toContainText(
+ "Player Without Account",
+ );
+
+ // reported results are loaded back into the form for editing
+ await reportWinners.goto(event.id);
+
+ await expect(reportWinners.locators.participantCountInput).toHaveValue(
+ "50",
+ );
+ await expect(reportWinners.locators.teamNameInput).toHaveValue(
+ "Team Olive",
+ );
+ await expect(reportWinners.locators.placingInput).toHaveValue("1");
+ await expect(reportWinners.player(1)).toContainText("N-ZAP");
+ await expect(reportWinners.player(2)).toHaveValue("Player Without Account");
+ });
});
diff --git a/e2e/pages/art/new-art-page.ts b/e2e/pages/art/new-art-page.ts
index 586e97cae..251a66683 100644
--- a/e2e/pages/art/new-art-page.ts
+++ b/e2e/pages/art/new-art-page.ts
@@ -1,24 +1,31 @@
import type { Page } from "@playwright/test";
+import type { Tables } from "~/db/tables";
+import { artFormSchema } from "~/features/art/art-schemas";
import { newArtPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
+import { createFormHelpers } from "../../helpers/playwright-form";
import { UserArtPage } from "./user-art-page";
/** `/art/new` */
export class NewArtPage {
private readonly page: Page;
readonly locators;
+ readonly form;
constructor(page: Page) {
this.page = page;
+ this.form = createFormHelpers(page, artFormSchema);
this.locators = {
fileInput: this.page.locator('input[type="file"]'),
preview: this.page.locator("form img"),
- saveButton: this.page.getByRole("button", { name: "Save" }),
+ existingImage: this.page.locator('form img[src*="-small."]'),
+ descriptionInput: this.page.getByLabel(this.form.getLabel("description")),
};
}
- async goto() {
- await navigate({ page: this.page, url: newArtPage() });
+ /** Given an art id, edits that art instead of uploading a new one. */
+ async goto(artId?: Tables["Art"]["id"]) {
+ await navigate({ page: this.page, url: newArtPage(artId) });
}
async selectImage(filePath: string) {
@@ -27,7 +34,7 @@ export class NewArtPage {
/** Lands on the uploader's own art page. */
async save() {
- await this.locators.saveButton.click();
+ await this.form.submit();
return new UserArtPage(this.page);
}
}
diff --git a/e2e/pages/art/user-art-page.ts b/e2e/pages/art/user-art-page.ts
index 41e39cc53..2c5ea12f7 100644
--- a/e2e/pages/art/user-art-page.ts
+++ b/e2e/pages/art/user-art-page.ts
@@ -1,5 +1,6 @@
import type { Page } from "@playwright/test";
-import { userArtPage } from "~/utils/urls";
+import type { Tables } from "~/db/tables";
+import { newArtPage, userArtPage } from "~/utils/urls";
import { navigate } from "../../helpers/playwright";
/** `/u/:id/art` */
@@ -22,4 +23,8 @@ export class UserArtPage {
image(nth: number) {
return this.locators.images.nth(nth);
}
+
+ editLink(artId: Tables["Art"]["id"]) {
+ return this.page.locator(`a[href="${newArtPage(artId)}"]`);
+ }
}
diff --git a/e2e/pages/ban/admin-ban-page.ts b/e2e/pages/ban/admin-ban-page.ts
index deb394728..82a7e0afc 100644
--- a/e2e/pages/ban/admin-ban-page.ts
+++ b/e2e/pages/ban/admin-ban-page.ts
@@ -1,5 +1,4 @@
import type { Locator, Page } from "@playwright/test";
-import { format } from "date-fns";
import { ADMIN_PAGE } from "~/utils/urls";
import {
navigate,
@@ -30,7 +29,7 @@ export class AdminBanPage {
async banUser(
userName: string,
- options: { until?: Date; reason?: string } = {},
+ options: { expiresAt?: Date; reason?: string } = {},
) {
const form = this.locators.banForm;
@@ -41,13 +40,11 @@ export class AdminBanPage {
within: form,
});
- if (options.until) {
- await form
- .locator('input[name="duration"]')
- .fill(format(options.until, "yyyy-MM-dd'T'HH:mm"));
+ if (options.expiresAt) {
+ await this.fillExpiresAt(options.expiresAt);
}
if (options.reason) {
- await form.locator('input[name="reason"]').fill(options.reason);
+ await form.getByLabel("Reason").fill(options.reason);
}
await this.save(form);
@@ -66,6 +63,26 @@ export class AdminBanPage {
await this.save(form);
}
+ private async fillExpiresAt(expiresAt: Date) {
+ const fillSegment = (segment: string, value: string) =>
+ this.locators.banForm
+ .getByRole("spinbutton", {
+ name: new RegExp(`^${segment}, Ban expiration date`),
+ })
+ .fill(value);
+
+ const hours = expiresAt.getHours();
+ await fillSegment("year", String(expiresAt.getFullYear()));
+ await fillSegment("month", String(expiresAt.getMonth() + 1));
+ await fillSegment("day", String(expiresAt.getDate()));
+ await fillSegment("hour", String(hours % 12 || 12));
+ await fillSegment(
+ "minute",
+ String(expiresAt.getMinutes()).padStart(2, "0"),
+ );
+ await fillSegment("AM/PM", hours >= 12 ? "PM" : "AM");
+ }
+
private async save(form: Locator) {
await waitForPOSTResponse(this.page, () =>
form.getByRole("button", { name: "Save" }).click(),
diff --git a/e2e/pages/calendar/calendar-event-page.ts b/e2e/pages/calendar/calendar-event-page.ts
new file mode 100644
index 000000000..bcbe2c53f
--- /dev/null
+++ b/e2e/pages/calendar/calendar-event-page.ts
@@ -0,0 +1,25 @@
+import type { Page } from "@playwright/test";
+import type { Tables } from "~/db/tables";
+import { calendarEventPage } from "~/utils/urls";
+import { navigate } from "../../helpers/playwright";
+
+/** `/calendar/:id` */
+export class CalendarEventPage {
+ private readonly page: Page;
+ readonly locators;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.locators = {
+ resultRows: page.getByRole("row"),
+ };
+ }
+
+ async goto(eventId: Tables["CalendarEvent"]["id"]) {
+ await navigate({ page: this.page, url: calendarEventPage(eventId) });
+ }
+
+ resultRow(teamName: string) {
+ return this.locators.resultRows.filter({ hasText: teamName });
+ }
+}
diff --git a/e2e/pages/calendar/report-winners-page.ts b/e2e/pages/calendar/report-winners-page.ts
new file mode 100644
index 000000000..be1505328
--- /dev/null
+++ b/e2e/pages/calendar/report-winners-page.ts
@@ -0,0 +1,55 @@
+import type { Page } from "@playwright/test";
+import type { Tables } from "~/db/tables";
+import { calendarReportWinnersPage } from "~/utils/urls";
+import { navigate, selectUser, submit } from "../../helpers/playwright";
+
+/** `/calendar/:id/report-winners` */
+export class ReportWinnersPage {
+ private readonly page: Page;
+ readonly locators;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.locators = {
+ participantCountInput: page.getByLabel("Participant count"),
+ teamNameInput: page.getByLabel("Team name"),
+ placingInput: page.getByLabel("Placing"),
+ emptyTeamError: page.getByText(
+ "Each team must have at least one player.",
+ ),
+ submitButton: page.getByTestId("submit-button"),
+ };
+ }
+
+ async goto(eventId: Tables["CalendarEvent"]["id"]) {
+ await navigate({
+ page: this.page,
+ url: calendarReportWinnersPage(eventId),
+ });
+ }
+
+ player(number: number) {
+ return this.page.getByLabel(`Player ${number}`);
+ }
+
+ async selectPlayer(number: number, userName: string) {
+ await selectUser({
+ page: this.page,
+ userName,
+ labelName: `Player ${number}`,
+ });
+ }
+
+ /** Players without a sendou.ink account are reported as plain text instead. */
+ async fillPlayerAsText(number: number, name: string) {
+ await this.page
+ .getByRole("button", { name: "Add as text" })
+ .nth(number - 1)
+ .click();
+ await this.player(number).fill(name);
+ }
+
+ async submit() {
+ await submit(this.page);
+ }
+}
diff --git a/locales/da/analyzer.json b/locales/da/analyzer.json
index e0a69eb5e..7a5b74659 100644
--- a/locales/da/analyzer.json
+++ b/locales/da/analyzer.json
@@ -23,6 +23,9 @@
"stat.specialPoints": "Point krævet for Speciale",
"stat.specialLost": "Specialeopladning tabt, når man bliver uskadeliggjort",
"stat.specialLostSplattedByRP": "Specialeopladning tabt, når man bliver uskadeliggjort af en RP-bruger",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Midlertidig pause for blækpåfyldning efter brug",
"stat.subWeaponInkConsumptionPercentage": "Blækforbrug",
"stat.squidFormInkRecoverySeconds": "Påfyldningstid af blæktank i blæksprutteform",
diff --git a/locales/da/art.json b/locales/da/art.json
index 92c5a3710..3f9db6da9 100644
--- a/locales/da/art.json
+++ b/locales/da/art.json
@@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Vær opmærksom på følgende: 1) Upload kun Splatoon-kunst 2) upload kun kunst, som du selv har lavet 3) Ingen NSFW-kunst. 4) Kunst skal igennem en valideringsproces før det vises til andre brugere.",
- "forms.description.title": "Beskrivelse",
- "forms.linkedUsers.title": "tilknyttede brugere",
- "forms.linkedUsers.anotherOne": "Endnu en",
- "forms.linkedUsers.info": "Hvem er i kunstværket? Hvis du tilknytter en bruger dit kunstværk giver du tilladelse til at det bliver hvis på hans/huns profil.",
- "forms.showcase.title": "Fremvist kunstværk",
- "forms.showcase.info": "Dit fremviste værk bliver vist på den fælles kunst-side. Du kan kun fremvise et værk af gangen.",
"forms.tags.title": "Etiketter",
"forms.tags.selectFromExisting": "Vælg ud fra eksisterende etiketter",
"forms.tags.cantFindExisting": "Kan du ikke finde en eksisterende etiket?",
diff --git a/locales/da/calendar.json b/locales/da/calendar.json
index 4604cbbfd..6a7069a19 100644
--- a/locales/da/calendar.json
+++ b/locales/da/calendar.json
@@ -21,21 +21,14 @@
"forms.badges": "Præmiemærker",
"forms.badges.placeholder": "Vælg et premiemærke",
"forms.mapPool": "Banepulje",
- "forms.participantCount": "Antal deltagere",
"forms.reportResultsHeader": "Viser resultater for {{eventName}}",
"forms.reportResultsInfo": "Du vælger hvor mange resultater der skal vises. Det kan være det vindende hold eller top 3.",
- "forms.team.add": "Tilføj hold",
- "forms.team.remove": "Fjern hold",
- "forms.team.name": "Holdnavn",
"forms.team.placing": "Placering",
"forms.team.player.header": "Spiller {{number}}",
"forms.team.player.add": "Tilføj spiller",
"forms.team.player.remove": "Fjern spiller",
"forms.team.player.addAsUser": "Tilføj som bruger (anbefales)",
"forms.team.player.addAsText": "Tilføj som tekst",
- "forms.errors.uniqueTeamName": "Alle hold skal have et unikt navn.",
- "forms.errors.duplicatePlayer": "Man kan ikke have den samme spiller på samme hold 2 gange.",
- "forms.errors.emptyTeam": "Alle hold skal have mindst en spiller.",
"tag.desc.SPECIAL": "Regelsættet afviger fra standardreglerne.",
"tag.desc.ART": "Man kan vinde kunst ved at deltage i denne turnering.",
"tag.desc.MONEY": "Man kan vinde penge ved at deltage i denne turnering",
diff --git a/locales/da/forms.json b/locales/da/forms.json
index b99f635d4..9bd3bdd84 100644
--- a/locales/da/forms.json
+++ b/locales/da/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Holdnavnet er taget af et andet hold",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Våbenpulje",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Beskrivelse",
"labels.user": "",
+ "labels.linkedUsers": "tilknyttede brugere",
+ "bottomTexts.linkedUsers": "Hvem er i kunstværket? Hvis du tilknytter en bruger dit kunstværk giver du tilladelse til at det bliver hvis på hans/huns profil.",
+ "labels.showcase": "Fremvist kunstværk",
+ "bottomTexts.showcase": "Dit fremviste værk bliver vist på den fælles kunst-side. Du kan kun fremvise et værk af gangen.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Antal deltagere",
+ "labels.teams": "",
+ "labels.teamName": "Holdnavn",
+ "labels.placement": "Placering",
+ "errors.emptyTeam": "Alle hold skal have mindst en spiller.",
+ "errors.duplicatePlayer": "Man kan ikke have den samme spiller på samme hold 2 gange.",
+ "errors.uniqueTeamName": "Alle hold skal have et unikt navn.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/da/friends.json b/locales/da/friends.json
index bccdf5dae..b672734aa 100644
--- a/locales/da/friends.json
+++ b/locales/da/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/da/q.json b/locales/da/q.json
index 23ed731dc..5907f99dc 100644
--- a/locales/da/q.json
+++ b/locales/da/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Kan hverken snakke eller lytte",
"vc.LISTEN_ONLY": "Kan kun lytte",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/da/scrims.json b/locales/da/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/da/scrims.json
+++ b/locales/da/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/da/user.json b/locales/da/user.json
index 798051909..c9a500c0e 100644
--- a/locales/da/user.json
+++ b/locales/da/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/de/analyzer.json b/locales/de/analyzer.json
index 39d965c0c..1bce63cff 100644
--- a/locales/de/analyzer.json
+++ b/locales/de/analyzer.json
@@ -23,6 +23,9 @@
"stat.specialPoints": "Punkte für Spezialwaffe",
"stat.specialLost": "Verlonene Spezialpunkte wenn erledigt",
"stat.specialLostSplattedByRP": "Verlonene Spezialpunkte wenn erledigt durch Spieler mit Heimsuchung-Effekt",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Dauer keiner Tintenregeneration nach Nutzung",
"stat.subWeaponInkConsumptionPercentage": "Tintentankverbrauch",
"stat.squidFormInkRecoverySeconds": "Dauer vollst. Tintenregeneration (als Tintenfisch)",
diff --git a/locales/de/art.json b/locales/de/art.json
index 26d9f7108..0dd4a60d8 100644
--- a/locales/de/art.json
+++ b/locales/de/art.json
@@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
- "forms.description.title": "",
- "forms.linkedUsers.title": "",
- "forms.linkedUsers.anotherOne": "",
- "forms.linkedUsers.info": "",
- "forms.showcase.title": "",
- "forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",
diff --git a/locales/de/calendar.json b/locales/de/calendar.json
index 01f4a3a97..d2c63797d 100644
--- a/locales/de/calendar.json
+++ b/locales/de/calendar.json
@@ -21,21 +21,14 @@
"forms.badges": "Abzeichen-Preis",
"forms.badges.placeholder": "Wähle ein Abzeichen für das Event",
"forms.mapPool": "Arenen-Pool",
- "forms.participantCount": "Anzahl Teilnehmer",
"forms.reportResultsHeader": "Berichten der Ergebnisse von {{eventName}}",
"forms.reportResultsInfo": "Die Anzahl der eintragbaren Ergebnisse ist frei wählbar. Es kann nur das erste Team sein, die Top 3 oder mehr.",
- "forms.team.add": "Team hinzufügen",
- "forms.team.remove": "Team löschen",
- "forms.team.name": "Name des Teams",
"forms.team.placing": "Platz",
"forms.team.player.header": "Spieler {{number}}",
"forms.team.player.add": "Spieler hinzufügen",
"forms.team.player.remove": "Spieler löschen",
"forms.team.player.addAsUser": "Hinzufügen als Benutzer (empfohlen)",
"forms.team.player.addAsText": "Hinzufügen als Text",
- "forms.errors.uniqueTeamName": "Zwei Teams dürfen nicht den gleichen Namen haben.",
- "forms.errors.duplicatePlayer": "Ein Spieler kann nicht doppelt in einem Team sein.",
- "forms.errors.emptyTeam": "Jedes Team brauch mindestens einen gelisteten Spieler.",
"tag.desc.SPECIAL": "Die Regeln des Events weichen vom Standard ab. (zum Beispiel: Limitierte Waffen).",
"tag.desc.ART": "In dem Event kann man einen Kunst-Preis gewinnen.",
"tag.desc.MONEY": "In dem Event kann man Geld gewinnen.",
diff --git a/locales/de/forms.json b/locales/de/forms.json
index 785810a49..1c99abee9 100644
--- a/locales/de/forms.json
+++ b/locales/de/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Es gibt bereits ein Team mit diesem Namen",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Waffenpool",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Beschreibung",
"labels.user": "",
+ "labels.linkedUsers": "",
+ "bottomTexts.linkedUsers": "",
+ "labels.showcase": "",
+ "bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Anzahl Teilnehmer",
+ "labels.teams": "",
+ "labels.teamName": "Name des Teams",
+ "labels.placement": "Platz",
+ "errors.emptyTeam": "Jedes Team brauch mindestens einen gelisteten Spieler.",
+ "errors.duplicatePlayer": "Ein Spieler kann nicht doppelt in einem Team sein.",
+ "errors.uniqueTeamName": "Zwei Teams dürfen nicht den gleichen Namen haben.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/de/friends.json b/locales/de/friends.json
index bccdf5dae..b672734aa 100644
--- a/locales/de/friends.json
+++ b/locales/de/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/de/q.json b/locales/de/q.json
index 4d96d5401..b5a83e632 100644
--- a/locales/de/q.json
+++ b/locales/de/q.json
@@ -12,12 +12,6 @@
"vc.NO": "",
"vc.LISTEN_ONLY": "",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/de/scrims.json b/locales/de/scrims.json
index b8c1508a2..38bb9b2b7 100644
--- a/locales/de/scrims.json
+++ b/locales/de/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "Geplanter Scrim",
"page.vs": "",
"associations.title": "Assoziationen",
diff --git a/locales/de/user.json b/locales/de/user.json
index 660c5ff0b..e3e2afa05 100644
--- a/locales/de/user.json
+++ b/locales/de/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/en/analyzer.json b/locales/en/analyzer.json
index 0ae192512..ae591d1fc 100644
--- a/locales/en/analyzer.json
+++ b/locales/en/analyzer.json
@@ -23,6 +23,9 @@
"stat.specialPoints": "Points to special",
"stat.specialLost": "Special lost when splatted",
"stat.specialLostSplattedByRP": "Special lost when splatted by RP user",
+ "stat.tenacitySecondsToSpecial_one": "Time to special with Tenacity ({{count}} down)",
+ "stat.tenacitySecondsToSpecial_other": "Time to special with Tenacity ({{count}} down)",
+ "stat.tenacitySecondsToSpecial.explanation": "How long it takes Tenacity to fill the special gauge from empty while your team has that many fewer active players than the opponent's team, e.g. {{teamPlayerCount}}v{{opponentPlayerCount}}. Only the difference between the teams matters, so an even matchup such as 3v3 does not charge the gauge at all.",
"stat.whiteInk": "No ink recovery time after usage",
"stat.subWeaponInkConsumptionPercentage": "Ink tank consumption",
"stat.squidFormInkRecoverySeconds": "Ink tank full recovery time (squid form)",
diff --git a/locales/en/art.json b/locales/en/art.json
index 9afa33411..3b4d1fb31 100644
--- a/locales/en/art.json
+++ b/locales/en/art.json
@@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "Recently Uploaded",
"tabs.showcase": "Showcase",
"forms.caveats": "A few things to note: 1) Only upload Splatoon art 2) Only upload art you made yourself 3) No NSFW art. There is a validation process before art is shown to other users.",
- "forms.description.title": "Description",
- "forms.linkedUsers.title": "Linked users",
- "forms.linkedUsers.anotherOne": "Another one",
- "forms.linkedUsers.info": "Who is in the art? Linking users allows your art to show up on their profile.",
- "forms.showcase.title": "Showcase",
- "forms.showcase.info": "Your showcase piece is shown on the common /art page. Only one piece can be your showcase at a time.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Select from existing tags",
"forms.tags.cantFindExisting": "Can't find an existing tag?",
diff --git a/locales/en/calendar.json b/locales/en/calendar.json
index 24a11a666..d7ec808fd 100644
--- a/locales/en/calendar.json
+++ b/locales/en/calendar.json
@@ -21,21 +21,14 @@
"forms.badges": "Badge prizes",
"forms.badges.placeholder": "Choose a badge prize",
"forms.mapPool": "Map pool",
- "forms.participantCount": "Participant count",
"forms.reportResultsHeader": "Reporting results for {{eventName}}",
"forms.reportResultsInfo": "You choose how many results to report. It can be just the winning team, top 3 or whatever you decide.",
- "forms.team.add": "Add team",
- "forms.team.remove": "Remove team",
- "forms.team.name": "Team name",
"forms.team.placing": "Placing",
"forms.team.player.header": "Player {{number}}",
"forms.team.player.add": "Add player",
"forms.team.player.remove": "Remove player",
"forms.team.player.addAsUser": "Add as a user (recommended)",
"forms.team.player.addAsText": "Add as text",
- "forms.errors.uniqueTeamName": "Each team needs a unique name.",
- "forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.",
- "forms.errors.emptyTeam": "Each team must have at least one player.",
"tag.desc.SPECIAL": "Ruleset that differs from the standard, e.g., limits what weapons can be used.",
"tag.desc.ART": "You can win art by playing in this tournament.",
"tag.desc.MONEY": "You can win money by playing in this tournament.",
diff --git a/locales/en/forms.json b/locales/en/forms.json
index 382350f29..b45ea5f45 100644
--- a/locales/en/forms.json
+++ b/locales/en/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "No SendouQ match found with this ID",
"errors.invalidUrl": "Must be a valid URL",
"errors.notAllowedCharacters": "Contains not allowed characters",
+ "errors.imageTooLarge": "Image is too large. Try one with a smaller file size.",
"errors.atLeastOneOption": "At least one option must be selected",
"errors.duplicateName": "There is already a team with this name",
"errors.duplicateOrgName": "There is already an organization with this name",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "Enter a name for the custom role",
"labels.weaponPool": "Weapon pool",
"placeholders.weaponPoolFull": "Pool full - remove a weapon to add more",
+ "placeholders.vodStartTimestamp": "10:22",
"labels.voiceChat": "Can voice chat",
"labels.languages": "Your languages",
"options.voiceChat.yes": "Yes",
@@ -147,6 +149,10 @@
"labels.urls": "URLs",
"labels.description": "Description",
"labels.user": "User",
+ "labels.linkedUsers": "Linked users",
+ "bottomTexts.linkedUsers": "Who is in the art? Linking users allows your art to show up on their profile.",
+ "labels.showcase": "Showcase",
+ "bottomTexts.showcase": "Your showcase piece is shown on the common /art page. Only one piece can be your showcase at a time.",
"labels.orgMemberRole": "Role",
"labels.orgMemberRoleDisplayName": "Role display name",
"labels.orgSocialLinks": "Social links",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "All",
"options.artSource.MADE-BY": "Made by me",
"options.artSource.MADE-OF": "Made of me",
+ "labels.controller": "Controller",
+ "options.controller.s1-pro-con": "Switch 1 Pro Controller",
+ "options.controller.s2-pro-con": "Switch 2 Pro Controller",
+ "options.controller.grip": "Joy-Con Grip",
+ "options.controller.handheld": "Handheld",
"labels.tierListUrl": "Tier List URL",
"labels.plusTier": "Tier",
"labels.comment": "Comment",
@@ -380,5 +391,34 @@
"errors.allModePool": "Map pool must contain a map for each ranked mode if using \"Prepicked by teams - All modes\"",
"errors.bracketUrlRequired": "Bracket URL is required",
"errors.bracketProgressionRequired": "Bracket progression must be set for tournaments",
- "errors.maxMembersRange": "Max team size must be between 4 and 10"
+ "errors.maxMembersRange": "Max team size must be between 4 and 10",
+ "labels.participantCount": "Participant count",
+ "labels.teams": "Teams",
+ "labels.teamName": "Team name",
+ "labels.placement": "Placing",
+ "errors.emptyTeam": "Each team must have at least one player.",
+ "errors.duplicatePlayer": "Can't have the same player twice in the same team.",
+ "errors.uniqueTeamName": "Each team needs a unique name.",
+ "errors.numberOutOfRange": "Number is out of the allowed range",
+ "labels.sentiment": "Sentiment",
+ "bottomTexts.sentiment": "Positive or negative sentiment affects their sorting for you in the queue",
+ "options.sentiment.POSITIVE": "Positive",
+ "options.sentiment.NEUTRAL": "Neutral",
+ "options.sentiment.NEGATIVE": "Negative",
+ "labels.adminOldUser": "Old user",
+ "labels.adminNewUser": "New user",
+ "labels.adminPlayerId": "Player ID",
+ "labels.friendCode": "Friend code",
+ "labels.patronTier": "Patron tier",
+ "labels.patronExpiresAt": "Patron until",
+ "labels.reason": "Reason",
+ "bottomTexts.adminMigrateNewUser": "Data on the new user will be deleted (e.g. builds)",
+ "errors.invalidFriendCode": "Invalid friend code",
+ "options.patronTier.1": "Support",
+ "options.patronTier.2": "Supporter",
+ "options.patronTier.3": "Supporter+",
+ "placeholders.friendCode": "1234-5678-9012",
+ "unsavedChanges.title": "Unsaved changes",
+ "unsavedChanges.body": "Are you sure you want to leave? Changes you made will not be saved.",
+ "unsavedChanges.discard": "Leave page"
}
diff --git a/locales/en/friends.json b/locales/en/friends.json
index 9c0c9f267..b606dc5d6 100644
--- a/locales/en/friends.json
+++ b/locales/en/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "View tournament",
"friendsList.viewMatch": "View match",
"friendsList.live": "Live",
+ "friendsList.inMatch": "Match",
+ "friendsList.nextMatch": "Next",
+ "friendsList.watchStream": "Watch stream",
"friendsList.joinSendouQ": "Join SendouQ",
"friendsList.deleteFriend": "Delete friend",
"friendsList.deleteConfirm": "Delete {{name}} as a friend?",
diff --git a/locales/en/q.json b/locales/en/q.json
index 631e31f7d..11bd8190f 100644
--- a/locales/en/q.json
+++ b/locales/en/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Can't voice chat",
"vc.LISTEN_ONLY": "Can only listen",
"privateNote.header": "Private note about {{name}}",
- "privateNote.comment.header": "Comment",
- "privateNote.sentiment.header": "Sentiment",
- "privateNote.sentiment.info": "Positive or negative sentiment affects their sorting for you in the queue",
- "privateNote.sentiment.POSITIVE": "Positive",
- "privateNote.sentiment.NEUTRAL": "Neutral",
- "privateNote.sentiment.NEGATIVE": "Negative",
"privateNote.delete.header": "Delete your note about {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "New York",
@@ -174,7 +168,7 @@
"match.timeline.loss": "Loss",
"match.timeline.out": "Out",
"match.timeline.in": "In",
- "match.timeline.live": "LIVE",
+ "match.timeline.ongoing": "ONGOING",
"match.timeline.picked": "Picked",
"match.timeline.explainer.picked": "Map picked by this team",
"match.timeline.explainer.pick": "Map or mode picked",
diff --git a/locales/en/scrims.json b/locales/en/scrims.json
index b06981c13..f9f8616f3 100644
--- a/locales/en/scrims.json
+++ b/locales/en/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "Ranked modes only",
"forms.maps.allModes": "All modes",
"forms.maps.tournament": "Tournament...",
- "forms.mapsTournament.title": "Tournament",
"page.scheduledScrim": "Scheduled scrim",
"page.vs": "vs. {{opponent}}",
"associations.title": "Associations",
diff --git a/locales/en/user.json b/locales/en/user.json
index 0270cc826..556907376 100644
--- a/locales/en/user.json
+++ b/locales/en/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "Favorite Stage",
"widgets.forms.weapon": "Weapon",
"widgets.forms.peakXp": "Peak XP",
- "widgets.forms.controller": "Controller",
"widgets.forms.source": "Art source",
"widgets.forms.source.ALL": "All",
"widgets.forms.source.MADE-BY": "Made by me",
diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json
index 917127ee1..066e3f9b3 100644
--- a/locales/es-ES/analyzer.json
+++ b/locales/es-ES/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Puntos para arma especial",
"stat.specialLost": "Especial perdido al ser reventado",
"stat.specialLostSplattedByRP": "Especial perdido al ser reventado por jugador con Castigo Póstumo",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Tiempo sin recuperar tinta después de uso",
"stat.subWeaponInkConsumptionPercentage": "Consumo del tanque de tinta",
"stat.squidFormInkRecoverySeconds": "Tiempo de recuperar completo el tanque de tinta (forma calamar)",
diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json
index 86c290159..77c412b62 100644
--- a/locales/es-ES/art.json
+++ b/locales/es-ES/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "Subidas recientemente",
"tabs.showcase": "Destacadas",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tú creaste; 3) No se permite arte inapropiado (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
- "forms.description.title": "Descripción",
- "forms.linkedUsers.title": "Enlaces de usuarios",
- "forms.linkedUsers.anotherOne": "Uno más",
- "forms.linkedUsers.info": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
- "forms.showcase.title": "Exhibición",
- "forms.showcase.info": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a la vez.",
"forms.tags.title": "Etiquetas",
"forms.tags.selectFromExisting": "Seleccionar etiquetas existentes",
"forms.tags.cantFindExisting": "¿No encuentras una etiqueta existente?",
diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json
index 686636b1d..eb85751b0 100644
--- a/locales/es-ES/calendar.json
+++ b/locales/es-ES/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",
- "forms.participantCount": "Participantes",
"forms.reportResultsHeader": "Anunciando resultados para {{eventName}}",
"forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.",
- "forms.team.add": "Añadir equipo",
- "forms.team.remove": "Eliminar equipo",
- "forms.team.name": "Nombre de equipo",
"forms.team.placing": "Lugar",
"forms.team.player.header": "Jugador {{number}}",
"forms.team.player.add": "Añadir jugador",
"forms.team.player.remove": "Eliminar jugador",
"forms.team.player.addAsUser": "Añadir como usuario (recomendado)",
"forms.team.player.addAsText": "Añadir solo texto",
- "forms.errors.uniqueTeamName": "Cada equipo requiere un nombre único.",
- "forms.errors.duplicatePlayer": "No se puede tener el mismo jugador dos veces en el mismo equipo.",
- "forms.errors.emptyTeam": "Cada equipo requiere por lo menos un jugador.",
"tag.desc.SPECIAL": "Reglas que difieren del estándar, por ejemplo: límite de armas que se pueden usar.",
"tag.desc.ART": "Puedes ganar arte jugando este torneo.",
"tag.desc.MONEY": "Puedes ganar dinero jugando este torneo.",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index 612ffc342..b0834196d 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "Debe ser una URL válida",
"errors.notAllowedCharacters": "Contiene caracteres no permitidos",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "Debe seleccionarse al menos una opción",
"errors.duplicateName": "Ya existe un equipo con ese nombre",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Selección de armas",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "Puede usar chat de voz",
"labels.languages": "Tus idiomas",
"options.voiceChat.yes": "Sí",
@@ -102,7 +104,7 @@
"labels.scrimMinDiv": "Div. mínima",
"labels.scrimMapSource": "",
"labels.scrimMapPool": "",
- "labels.scrimMapsTournament": "",
+ "labels.scrimMapsTournament": "Torneo",
"placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "",
"options.scrimMapSource.TOURNAMENT": "",
@@ -147,6 +149,10 @@
"labels.urls": "URLs",
"labels.description": "Descripción",
"labels.user": "Usuario",
+ "labels.linkedUsers": "Enlaces de usuarios",
+ "bottomTexts.linkedUsers": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
+ "labels.showcase": "Exhibición",
+ "bottomTexts.showcase": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a la vez.",
"labels.orgMemberRole": "Rol",
"labels.orgMemberRoleDisplayName": "Nombre del rol",
"labels.orgSocialLinks": "Enlaces sociales",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "Todos",
"options.artSource.MADE-BY": "Creado por mí",
"options.artSource.MADE-OF": "Sobre mí",
+ "labels.controller": "Mando",
+ "options.controller.s1-pro-con": "Mando Pro de Switch 1",
+ "options.controller.s2-pro-con": "Mando Pro de Switch 2",
+ "options.controller.grip": "Soporte para Joy-Con",
+ "options.controller.handheld": "Modo portátil",
"labels.tierListUrl": "URL de Tier List",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Comentario",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Participantes",
+ "labels.teams": "",
+ "labels.teamName": "Nombre de equipo",
+ "labels.placement": "Lugar",
+ "errors.emptyTeam": "Cada equipo requiere por lo menos un jugador.",
+ "errors.duplicatePlayer": "No se puede tener el mismo jugador dos veces en el mismo equipo.",
+ "errors.uniqueTeamName": "Cada equipo requiere un nombre único.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Opinión",
+ "bottomTexts.sentiment": "Opinión positiva o negativa afecta el orden de otros en la fila",
+ "options.sentiment.POSITIVE": "Positivo",
+ "options.sentiment.NEUTRAL": "Neutral",
+ "options.sentiment.NEGATIVE": "Negativo",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/es-ES/friends.json
+++ b/locales/es-ES/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json
index 4d8d679c6..d9f182354 100644
--- a/locales/es-ES/q.json
+++ b/locales/es-ES/q.json
@@ -12,12 +12,6 @@
"vc.NO": "No puede chatear por voz",
"vc.LISTEN_ONLY": "Solo puede escuchar",
"privateNote.header": "Nota privada sobre {{name}}",
- "privateNote.comment.header": "Comentario",
- "privateNote.sentiment.header": "Opinión",
- "privateNote.sentiment.info": "Opinión positiva o negativa afecta el orden de otros en la fila",
- "privateNote.sentiment.POSITIVE": "Positivo",
- "privateNote.sentiment.NEUTRAL": "Neutral",
- "privateNote.sentiment.NEGATIVE": "Negativo",
"privateNote.delete.header": "¿Borrar tu nota sobre {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "Nueva York",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/es-ES/scrims.json b/locales/es-ES/scrims.json
index 2731c6945..61832f906 100644
--- a/locales/es-ES/scrims.json
+++ b/locales/es-ES/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "Solo modos competitivos",
"forms.maps.allModes": "Todos los modos",
"forms.maps.tournament": "Torneo...",
- "forms.mapsTournament.title": "Torneo",
"page.scheduledScrim": "Scrim programado",
"page.vs": "",
"associations.title": "Asociaciones",
diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json
index 998d4edc5..5e0296a8d 100644
--- a/locales/es-ES/user.json
+++ b/locales/es-ES/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "Escenario favorito",
"widgets.forms.weapon": "Arma",
"widgets.forms.peakXp": "Energía X máxima",
- "widgets.forms.controller": "Mando",
"widgets.forms.source": "Fuente del arte",
"widgets.forms.source.ALL": "Todo",
"widgets.forms.source.MADE-BY": "Hecho por mí",
diff --git a/locales/es-US/analyzer.json b/locales/es-US/analyzer.json
index b7e65f778..418e025b8 100644
--- a/locales/es-US/analyzer.json
+++ b/locales/es-US/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Puntos para arma especial",
"stat.specialLost": "Especial perdido al ser reventado",
"stat.specialLostSplattedByRP": "Especial perdido al ser reventado por jugador con Castigo Póstumo",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Tiempo sin recuperar tinta después de uso",
"stat.subWeaponInkConsumptionPercentage": "Consumo del tanque de tinta",
"stat.squidFormInkRecoverySeconds": "Tiempo de recuperar completo el tanque de tinta (forma nadadora)",
diff --git a/locales/es-US/art.json b/locales/es-US/art.json
index 4c2ac7c37..1b6ef6632 100644
--- a/locales/es-US/art.json
+++ b/locales/es-US/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "NOTAS: 1) Solo sube arte de Splatoon; 2) Solo sube arte que tu creaste; 3) No se permite arte inapropiada (NSFW). Hay un proceso de evaluación antes de que se muestre tu arte públicamente.",
- "forms.description.title": "Descripción",
- "forms.linkedUsers.title": "Enlaces de usuarios",
- "forms.linkedUsers.anotherOne": "Uno más",
- "forms.linkedUsers.info": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
- "forms.showcase.title": "Exhibición",
- "forms.showcase.info": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a a la vez.",
"forms.tags.title": "Etiquetas",
"forms.tags.selectFromExisting": "Seleccionar etiquetas existentes",
"forms.tags.cantFindExisting": "¿No eucuentras una etiqueta existente?",
diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json
index d4d13b7e9..c9cd66ce0 100644
--- a/locales/es-US/calendar.json
+++ b/locales/es-US/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Premios de insignia",
"forms.badges.placeholder": "Elige un premio de insignia",
"forms.mapPool": "Grupo de mapas",
- "forms.participantCount": "Participantes",
"forms.reportResultsHeader": "Anunciando resultados para {{eventName}}",
"forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.",
- "forms.team.add": "Añadir equipo",
- "forms.team.remove": "Remover equipo",
- "forms.team.name": "Nombre de equipo",
"forms.team.placing": "Lugar",
"forms.team.player.header": "Jugador {{number}}",
"forms.team.player.add": "Añadir jugador",
"forms.team.player.remove": "Remover jugador",
"forms.team.player.addAsUser": "Añadir como usuario (recomendado)",
"forms.team.player.addAsText": "Añadir solo texto",
- "forms.errors.uniqueTeamName": "Cada equipo requiere un nombre único.",
- "forms.errors.duplicatePlayer": "No se puede tener el mismo jugador dos veces en el mismo equipo.",
- "forms.errors.emptyTeam": "Cada equipo requiere por lo menos un jugador.",
"tag.desc.SPECIAL": "Reglas que difieren del estándar, por ejemplo: limite de armas que se pueden usar.",
"tag.desc.ART": "Puedes ganar arte jugando este torneo.",
"tag.desc.MONEY": "Puedes ganar dinero jugando este torneo.",
diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json
index ab0e6e34b..10c05eed4 100644
--- a/locales/es-US/forms.json
+++ b/locales/es-US/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Ya existe un equipo con ese nombre",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Grupo de armas",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Descripción",
"labels.user": "Usuario",
+ "labels.linkedUsers": "Enlaces de usuarios",
+ "bottomTexts.linkedUsers": "¿Quién aparece en tu arte? Agregar enlaces de usuarios hace posible que tu arte se muestre en el perfil del usuario.",
+ "labels.showcase": "Exhibición",
+ "bottomTexts.showcase": "Tu pieza de exhibición se muestra en la página /arte común. Solo una pieza puede servir de exhibición a a la vez.",
"labels.orgMemberRole": "Rol",
"labels.orgMemberRoleDisplayName": "Nombre del rol",
"labels.orgSocialLinks": "Enlaces sociales",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Comentario",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Participantes",
+ "labels.teams": "",
+ "labels.teamName": "Nombre de equipo",
+ "labels.placement": "Lugar",
+ "errors.emptyTeam": "Cada equipo requiere por lo menos un jugador.",
+ "errors.duplicatePlayer": "No se puede tener el mismo jugador dos veces en el mismo equipo.",
+ "errors.uniqueTeamName": "Cada equipo requiere un nombre único.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Opinión",
+ "bottomTexts.sentiment": "Opinión positiva o negativa afecta el orden de otros en la fila",
+ "options.sentiment.POSITIVE": "Positivo",
+ "options.sentiment.NEUTRAL": "Neutral",
+ "options.sentiment.NEGATIVE": "Negativo",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/es-US/friends.json b/locales/es-US/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/es-US/friends.json
+++ b/locales/es-US/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/es-US/q.json b/locales/es-US/q.json
index bab31db12..9b19c731c 100644
--- a/locales/es-US/q.json
+++ b/locales/es-US/q.json
@@ -12,12 +12,6 @@
"vc.NO": "No puede chatear por voz",
"vc.LISTEN_ONLY": "Solo puede escuchar",
"privateNote.header": "Nota privada sobre {{name}}",
- "privateNote.comment.header": "Comentario",
- "privateNote.sentiment.header": "Opinión",
- "privateNote.sentiment.info": "Opinión positiva o negativa afecta el orden de otros en la fila",
- "privateNote.sentiment.POSITIVE": "Positivo",
- "privateNote.sentiment.NEUTRAL": "Neutral",
- "privateNote.sentiment.NEGATIVE": "Negativo",
"privateNote.delete.header": "¿Borrar tu nota sobre {{name}}?",
"front.cities.la": "Los Ángeles",
"front.cities.nyc": "Nueva York",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/es-US/scrims.json b/locales/es-US/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/es-US/scrims.json
+++ b/locales/es-US/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/es-US/user.json b/locales/es-US/user.json
index 3ea40de6e..abe4f287c 100644
--- a/locales/es-US/user.json
+++ b/locales/es-US/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/fr-CA/analyzer.json b/locales/fr-CA/analyzer.json
index d0547d587..dbb3e79c0 100644
--- a/locales/fr-CA/analyzer.json
+++ b/locales/fr-CA/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Seuil arme spéciale",
"stat.specialLost": "Jauge perdue quand liquidé",
"stat.specialLostSplattedByRP": "Jauge perdue quand liquidé (RP)",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Délai avant recharge de l'encre",
"stat.subWeaponInkConsumptionPercentage": "Consommation du réservoir d'encre",
"stat.squidFormInkRecoverySeconds": "Temps de recharge du réservoir (calamar)",
diff --git a/locales/fr-CA/art.json b/locales/fr-CA/art.json
index 438c93ae5..e88421047 100644
--- a/locales/fr-CA/art.json
+++ b/locales/fr-CA/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
- "forms.description.title": "Description",
- "forms.linkedUsers.title": "Utilisateurs liés",
- "forms.linkedUsers.anotherOne": "Un autre",
- "forms.linkedUsers.info": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
- "forms.showcase.title": "Épinglée",
- "forms.showcase.info": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Selectionner depuis des tags existants",
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",
diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json
index 8ae52253c..9f762de6a 100644
--- a/locales/fr-CA/calendar.json
+++ b/locales/fr-CA/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",
- "forms.participantCount": "Nombre de participants",
"forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}",
"forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.",
- "forms.team.add": "Ajouter une équipe",
- "forms.team.remove": "Retirer une équipe",
- "forms.team.name": "Nom de l'équipe",
"forms.team.placing": "Position",
"forms.team.player.header": "Joueur {{number}}",
"forms.team.player.add": "Ajouter un joueur",
"forms.team.player.remove": "Retirer un joueur",
"forms.team.player.addAsUser": "Ajouter en tant qu'utilisateur (recommandé)",
"forms.team.player.addAsText": "Ajouter en tant que texte brut",
- "forms.errors.uniqueTeamName": "Chaque équipe doit avoir un nom unique.",
- "forms.errors.duplicatePlayer": "Impossible d'avoir le même joueur plusieurs fois dans la même équipe.",
- "forms.errors.emptyTeam": "Chaque équipe doit au moins avoir un joueur.",
"tag.desc.SPECIAL": "Les règles peuvent différer du standard, par ex. une sélection limitée d'armes.",
"tag.desc.ART": "Il est possible de gagner une illustration dans ce tournoi.",
"tag.desc.MONEY": "Il est possible de gagner de l'argent dans ce tournoi.",
diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json
index 8ef66b704..b5eb73676 100644
--- a/locales/fr-CA/forms.json
+++ b/locales/fr-CA/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Il y a déjà une équipe avec ce nom",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Description",
"labels.user": "",
+ "labels.linkedUsers": "Utilisateurs liés",
+ "bottomTexts.linkedUsers": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
+ "labels.showcase": "Épinglée",
+ "bottomTexts.showcase": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Nombre de participants",
+ "labels.teams": "",
+ "labels.teamName": "Nom de l'équipe",
+ "labels.placement": "Position",
+ "errors.emptyTeam": "Chaque équipe doit au moins avoir un joueur.",
+ "errors.duplicatePlayer": "Impossible d'avoir le même joueur plusieurs fois dans la même équipe.",
+ "errors.uniqueTeamName": "Chaque équipe doit avoir un nom unique.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/fr-CA/friends.json b/locales/fr-CA/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/fr-CA/friends.json
+++ b/locales/fr-CA/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json
index d58d9e3ae..2d9b5d4d1 100644
--- a/locales/fr-CA/q.json
+++ b/locales/fr-CA/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Vocal impossible",
"vc.LISTEN_ONLY": "Écoute seulement",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/fr-CA/scrims.json b/locales/fr-CA/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/fr-CA/scrims.json
+++ b/locales/fr-CA/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json
index 1f113483c..b4b8eb7b6 100644
--- a/locales/fr-CA/user.json
+++ b/locales/fr-CA/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/fr-EU/analyzer.json b/locales/fr-EU/analyzer.json
index 82bf2a8be..3eeda3431 100644
--- a/locales/fr-EU/analyzer.json
+++ b/locales/fr-EU/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Seuil arme spéciale",
"stat.specialLost": "Jauge perdue quand liquidé",
"stat.specialLostSplattedByRP": "Jauge perdue quand liquidé (RP)",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Délai avant recharge de l'encre",
"stat.subWeaponInkConsumptionPercentage": "Consommation du réservoir d'encre",
"stat.squidFormInkRecoverySeconds": "Temps de recharge du réservoir (calamar)",
diff --git a/locales/fr-EU/art.json b/locales/fr-EU/art.json
index 1094b9fe0..f31a57313 100644
--- a/locales/fr-EU/art.json
+++ b/locales/fr-EU/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Quelques notes: 1) Doit être en rapport avec Splatoon 2) Doit avoir été créé par vous 3) Pas de contenu NSFW/explicite. Il y a une procédure de validation avant que votre poste puisse être vu par les autres.",
- "forms.description.title": "Description",
- "forms.linkedUsers.title": "Utilisateurs liés",
- "forms.linkedUsers.anotherOne": "Un autre",
- "forms.linkedUsers.info": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
- "forms.showcase.title": "Épinglée",
- "forms.showcase.info": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"forms.tags.title": "Tags",
"forms.tags.selectFromExisting": "Selectionner depuis des tags existants",
"forms.tags.cantFindExisting": "Impossible de trouver un tag existant ?",
diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json
index 8ae52253c..9f762de6a 100644
--- a/locales/fr-EU/calendar.json
+++ b/locales/fr-EU/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Badge à gagner",
"forms.badges.placeholder": "Choisissez le badge à faire gagner",
"forms.mapPool": "Stages disponibles",
- "forms.participantCount": "Nombre de participants",
"forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}",
"forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.",
- "forms.team.add": "Ajouter une équipe",
- "forms.team.remove": "Retirer une équipe",
- "forms.team.name": "Nom de l'équipe",
"forms.team.placing": "Position",
"forms.team.player.header": "Joueur {{number}}",
"forms.team.player.add": "Ajouter un joueur",
"forms.team.player.remove": "Retirer un joueur",
"forms.team.player.addAsUser": "Ajouter en tant qu'utilisateur (recommandé)",
"forms.team.player.addAsText": "Ajouter en tant que texte brut",
- "forms.errors.uniqueTeamName": "Chaque équipe doit avoir un nom unique.",
- "forms.errors.duplicatePlayer": "Impossible d'avoir le même joueur plusieurs fois dans la même équipe.",
- "forms.errors.emptyTeam": "Chaque équipe doit au moins avoir un joueur.",
"tag.desc.SPECIAL": "Les règles peuvent différer du standard, par ex. une sélection limitée d'armes.",
"tag.desc.ART": "Il est possible de gagner une illustration dans ce tournoi.",
"tag.desc.MONEY": "Il est possible de gagner de l'argent dans ce tournoi.",
diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json
index 78d63208a..d43685adc 100644
--- a/locales/fr-EU/forms.json
+++ b/locales/fr-EU/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Il y a déjà une équipe avec ce nom",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Armes jouées",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Description",
"labels.user": "utilisateur",
+ "labels.linkedUsers": "Utilisateurs liés",
+ "bottomTexts.linkedUsers": "Qui est présent? Lié un utilisateur permet à votre création d'être affichée sur leur profil.",
+ "labels.showcase": "Épinglée",
+ "bottomTexts.showcase": "Épingler une illustration l'affichera sur la page commune /art. Seule une illustration peut être épinglée à la fois.",
"labels.orgMemberRole": "Role",
"labels.orgMemberRoleDisplayName": "Nom d'affichage du rôle",
"labels.orgSocialLinks": "Liens de réseau saciaux",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Commentaire",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Nombre de participants",
+ "labels.teams": "",
+ "labels.teamName": "Nom de l'équipe",
+ "labels.placement": "Position",
+ "errors.emptyTeam": "Chaque équipe doit au moins avoir un joueur.",
+ "errors.duplicatePlayer": "Impossible d'avoir le même joueur plusieurs fois dans la même équipe.",
+ "errors.uniqueTeamName": "Chaque équipe doit avoir un nom unique.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Avis",
+ "bottomTexts.sentiment": "Les avis positifs ou négatifs affectent leur tri dans la file d'attente",
+ "options.sentiment.POSITIVE": "Positif",
+ "options.sentiment.NEUTRAL": "Neutre",
+ "options.sentiment.NEGATIVE": "Negatif",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/fr-EU/friends.json b/locales/fr-EU/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/fr-EU/friends.json
+++ b/locales/fr-EU/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json
index fe5968e4a..284aa0943 100644
--- a/locales/fr-EU/q.json
+++ b/locales/fr-EU/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Vocal impossible",
"vc.LISTEN_ONLY": "Écoute seulement",
"privateNote.header": "Note privée à propos de {{name}}",
- "privateNote.comment.header": "Commentaire",
- "privateNote.sentiment.header": "Avis",
- "privateNote.sentiment.info": "Les avis positifs ou négatifs affectent leur tri dans la file d'attente",
- "privateNote.sentiment.POSITIVE": "Positif",
- "privateNote.sentiment.NEUTRAL": "Neutre",
- "privateNote.sentiment.NEGATIVE": "Negatif",
"privateNote.delete.header": "Enlever votre note à propos de {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "New York",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/fr-EU/scrims.json b/locales/fr-EU/scrims.json
index ca987d87d..5d38dcc71 100644
--- a/locales/fr-EU/scrims.json
+++ b/locales/fr-EU/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "Scrim programmé",
"page.vs": "",
"associations.title": "Association",
diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json
index f0042f695..ea45ddbd6 100644
--- a/locales/fr-EU/user.json
+++ b/locales/fr-EU/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/he/analyzer.json b/locales/he/analyzer.json
index 10c6a070a..eabce1704 100644
--- a/locales/he/analyzer.json
+++ b/locales/he/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "נקודות לספיישל",
"stat.specialLost": "כמות נקודות שנאבדו לאחר שנותז",
"stat.specialLostSplattedByRP": "כמות נקודות שנאבדו לאחר שנותז על ידי שחקן עם RP",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_two": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "זמן ללא שחזור דיו לאחר שימוש",
"stat.subWeaponInkConsumptionPercentage": "צריכת דיו מהמיכל",
"stat.squidFormInkRecoverySeconds": "זמן התאוששות מלא של מיכל הדיו (צורת דיונון)",
diff --git a/locales/he/art.json b/locales/he/art.json
index f64df4eb3..ddaf7eafd 100644
--- a/locales/he/art.json
+++ b/locales/he/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "הועלה לאחרונה",
"tabs.showcase": "תצוגה",
"forms.caveats": "כמה הבהרות: 1) רק להעלות ציור של Splatoon 2) רק להעלות ציור שנעשתה על ידכם 3) בלי NSFW. יש תהליך בדיקה לפני שציור מופיעה למשתמשים אחרים.",
- "forms.description.title": "תיאור",
- "forms.linkedUsers.title": "תיוג משתמשים",
- "forms.linkedUsers.anotherOne": "עוד אחד",
- "forms.linkedUsers.info": "מי בציור? תיוג משתמשים מאפשר לציור שלכם להופיע בפרופיל שלהם.",
- "forms.showcase.title": "מוצג",
- "forms.showcase.info": "המוצג שלכם יופיע בעמוד /art. רק מוצג אחד מותר בכל פעם.",
"forms.tags.title": "תגים",
"forms.tags.selectFromExisting": "בחר מתגים קיימים",
"forms.tags.cantFindExisting": "לא מוצא תג קיים?",
diff --git a/locales/he/calendar.json b/locales/he/calendar.json
index 56cb5ba1f..469b6f955 100644
--- a/locales/he/calendar.json
+++ b/locales/he/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "פרסי תגים",
"forms.badges.placeholder": "בחירת פרס תג",
"forms.mapPool": "מאגר מפות",
- "forms.participantCount": "כמות משתתפים",
"forms.reportResultsHeader": "דיווח תוצאות עבור {{eventName}}",
"forms.reportResultsInfo": "אתם בוחרים כמה תוצאות לדווח. זה יכול להיות רק הצוות המנצח, הטופ מ-3 או כל מה שתחליטו.",
- "forms.team.add": "הוספת צוות",
- "forms.team.remove": "הורדת צוות",
- "forms.team.name": "שם צוות",
"forms.team.placing": "מיקום",
"forms.team.player.header": "שחקן {{number}}",
"forms.team.player.add": "הוספת שחקן",
"forms.team.player.remove": "הורדת שחקן",
"forms.team.player.addAsUser": "הוספה בתור משתמש (מומלץ)",
"forms.team.player.addAsText": "הוספה בטקסט",
- "forms.errors.uniqueTeamName": "כל צוות צריך שם יחודי.",
- "forms.errors.duplicatePlayer": "לא יכול להיות אותו שחקן פעמיים באותה קבוצה.",
- "forms.errors.emptyTeam": "בכל קבוצה חייב להיות שחקן אחד לפחות.",
"tag.desc.SPECIAL": "כללים השונים מהתקן הרגיל. (למשל שימוש מוגבל באלו נשקים ניתן להשתמש)",
"tag.desc.ART": "ניתן לזכות בציור על ידי השתתפות בטורניר זה.",
"tag.desc.MONEY": "ניתן לזכות בכסף על ידי השתתפות בטורניר זה.",
diff --git a/locales/he/forms.json b/locales/he/forms.json
index 1565d5965..93cb8c2fb 100644
--- a/locales/he/forms.json
+++ b/locales/he/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "יש כבר צוות בשם הזה",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "מאגר נשקים",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "תיאור",
"labels.user": "",
+ "labels.linkedUsers": "תיוג משתמשים",
+ "bottomTexts.linkedUsers": "מי בציור? תיוג משתמשים מאפשר לציור שלכם להופיע בפרופיל שלהם.",
+ "labels.showcase": "מוצג",
+ "bottomTexts.showcase": "המוצג שלכם יופיע בעמוד /art. רק מוצג אחד מותר בכל פעם.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "כמות משתתפים",
+ "labels.teams": "",
+ "labels.teamName": "שם צוות",
+ "labels.placement": "מיקום",
+ "errors.emptyTeam": "בכל קבוצה חייב להיות שחקן אחד לפחות.",
+ "errors.duplicatePlayer": "לא יכול להיות אותו שחקן פעמיים באותה קבוצה.",
+ "errors.uniqueTeamName": "כל צוות צריך שם יחודי.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/he/friends.json b/locales/he/friends.json
index 781eb0c23..4dc3d11aa 100644
--- a/locales/he/friends.json
+++ b/locales/he/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/he/q.json b/locales/he/q.json
index 70b98ad14..d9ffb9e9b 100644
--- a/locales/he/q.json
+++ b/locales/he/q.json
@@ -12,12 +12,6 @@
"vc.NO": "לא יכול צ'אט קולי",
"vc.LISTEN_ONLY": "יכול רק להקשיב",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/he/scrims.json b/locales/he/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/he/scrims.json
+++ b/locales/he/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/he/user.json b/locales/he/user.json
index d163a5528..f52c8d522 100644
--- a/locales/he/user.json
+++ b/locales/he/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/it/analyzer.json b/locales/it/analyzer.json
index 82accc109..a6e3fc21b 100644
--- a/locales/it/analyzer.json
+++ b/locales/it/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Punti necessari carica arma speciale",
"stat.specialLost": "Carica speciale persa quando splattato",
"stat.specialLostSplattedByRP": "Carica speciale persa (splattato da utente Castigo)",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Tempo senza ricarica inchiostro dopo l'utilizzo",
"stat.subWeaponInkConsumptionPercentage": "Consumo serbatoio inchisotro",
"stat.squidFormInkRecoverySeconds": "Tempo ricarica piena serbatoio (forma calamaro)",
diff --git a/locales/it/art.json b/locales/it/art.json
index e09ea1a4d..ab0f3b6c5 100644
--- a/locales/it/art.json
+++ b/locales/it/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Un paio di cose di cui tener conto: 1) Puoi caricare soltanto art relative a Splatoon 2) Puoi caricare soltanto art create da te 3) Niente art NSFW. Vi è un processo di convalida svolto prima che la propria art sia visibile agli altri utenti.",
- "forms.description.title": "Descrizione",
- "forms.linkedUsers.title": "Utenti collegati",
- "forms.linkedUsers.anotherOne": "Un'altra",
- "forms.linkedUsers.info": "Chi è presente nell'art? Collegare utenti permette alla tua art di comparire sui loro profili.",
- "forms.showcase.title": "Presentazione",
- "forms.showcase.info": "La tua opera per la presentazione è mostrata nella pagina comune /art. Può essere usata come presentazione una sola tua opera per volta",
"forms.tags.title": "Tag",
"forms.tags.selectFromExisting": "Seleziona da tag esistenti",
"forms.tags.cantFindExisting": "Non riesci a trovare un tag esistente?",
diff --git a/locales/it/calendar.json b/locales/it/calendar.json
index 27bb3b219..947670537 100644
--- a/locales/it/calendar.json
+++ b/locales/it/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Medaglie in palio",
"forms.badges.placeholder": "Scegli una medaglia come premio",
"forms.mapPool": "Pool di scenari",
- "forms.participantCount": "Numero partecipante",
"forms.reportResultsHeader": "Reporting results for {{eventName}}",
"forms.reportResultsInfo": "Puoi scegliere quanti risultati vuoi riportare, se fare solo la squadra vincitrice, la top 3 o un'altra combinazione.",
- "forms.team.add": "Aggiungi team",
- "forms.team.remove": "Togli team",
- "forms.team.name": "Nome team",
"forms.team.placing": "Risultato",
"forms.team.player.header": "Giocatore {{number}}",
"forms.team.player.add": "Aggiungi giocatore",
"forms.team.player.remove": "Rimuovi giocatore",
"forms.team.player.addAsUser": "Aggiungi come utente (raccomandato)",
"forms.team.player.addAsText": "Aggiungi come testo",
- "forms.errors.uniqueTeamName": "Tutte le squadre devono avere un nome unico.",
- "forms.errors.duplicatePlayer": "Non puoi avere lo stesso giocatore due volte nella stessa squadra.",
- "forms.errors.emptyTeam": "Tutte le squadre devono avere almeno un giocatore.",
"tag.desc.SPECIAL": "Regole non standard, per esempio un limite su le armi che si possono utilizzare.",
"tag.desc.ART": "Puoi vincere dell'arte giocando in questo torneo.",
"tag.desc.MONEY": "Puoi vincere dei soldi giocando in questo torneo.",
diff --git a/locales/it/forms.json b/locales/it/forms.json
index e0ffaf2e5..f49b87dc9 100644
--- a/locales/it/forms.json
+++ b/locales/it/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Esiste già un team con questo nome",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Pool armi",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Descrizione",
"labels.user": "Utente",
+ "labels.linkedUsers": "Utenti collegati",
+ "bottomTexts.linkedUsers": "Chi è presente nell'art? Collegare utenti permette alla tua art di comparire sui loro profili.",
+ "labels.showcase": "Presentazione",
+ "bottomTexts.showcase": "La tua opera per la presentazione è mostrata nella pagina comune /art. Può essere usata come presentazione una sola tua opera per volta",
"labels.orgMemberRole": "Ruolo",
"labels.orgMemberRoleDisplayName": "Nome visualizzato ruolo",
"labels.orgSocialLinks": "Link per social",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Commento",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Numero partecipante",
+ "labels.teams": "",
+ "labels.teamName": "Nome team",
+ "labels.placement": "Risultato",
+ "errors.emptyTeam": "Tutte le squadre devono avere almeno un giocatore.",
+ "errors.duplicatePlayer": "Non puoi avere lo stesso giocatore due volte nella stessa squadra.",
+ "errors.uniqueTeamName": "Tutte le squadre devono avere un nome unico.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Sentimento",
+ "bottomTexts.sentiment": "Sentimenti positivi o negativi influenzano il loro ordinamento per te in coda",
+ "options.sentiment.POSITIVE": "Positivo",
+ "options.sentiment.NEUTRAL": "Neutrale",
+ "options.sentiment.NEGATIVE": "Negativo",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/it/friends.json b/locales/it/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/it/friends.json
+++ b/locales/it/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/it/q.json b/locales/it/q.json
index 8228f48cc..b09c74809 100644
--- a/locales/it/q.json
+++ b/locales/it/q.json
@@ -12,12 +12,6 @@
"vc.NO": "No VC",
"vc.LISTEN_ONLY": "Ascolta soltanto",
"privateNote.header": "Nota privata su {{name}}",
- "privateNote.comment.header": "Commento",
- "privateNote.sentiment.header": "Sentimento",
- "privateNote.sentiment.info": "Sentimenti positivi o negativi influenzano il loro ordinamento per te in coda",
- "privateNote.sentiment.POSITIVE": "Positivo",
- "privateNote.sentiment.NEUTRAL": "Neutrale",
- "privateNote.sentiment.NEGATIVE": "Negativo",
"privateNote.delete.header": "Cancellare la tua nota su {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "New York",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/it/scrims.json b/locales/it/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/it/scrims.json
+++ b/locales/it/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/it/user.json b/locales/it/user.json
index b2dbd4773..7ae4f668d 100644
--- a/locales/it/user.json
+++ b/locales/it/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/ja/analyzer.json b/locales/ja/analyzer.json
index 68793efbb..9ebdbbe58 100644
--- a/locales/ja/analyzer.json
+++ b/locales/ja/analyzer.json
@@ -23,6 +23,7 @@
"stat.specialPoints": "スペシャルポイント",
"stat.specialLost": "やられた場合に失うスペシャルポイント数",
"stat.specialLostSplattedByRP": "復活ペナルティアップ付きでやられた場合に失うスペシャルポイント数",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "インクロック",
"stat.subWeaponInkConsumptionPercentage": "インクタンク消費量(%)",
"stat.squidFormInkRecoverySeconds": "インクタンクの完全回復までの時間(イカ状態)",
diff --git a/locales/ja/art.json b/locales/ja/art.json
index 16a5bc3dd..3309b8fc9 100644
--- a/locales/ja/art.json
+++ b/locales/ja/art.json
@@ -11,12 +11,6 @@
"tabs.recentlyUploaded": "最近のアップロード",
"tabs.showcase": "作品紹介",
"forms.caveats": "注意点: 1) スプラトゥーンの作品のみ追加してください 2) 自分で作成した作品のみ追加してください 3) NSFW(R18系)は NG. 他のユーザーに公表される前に確認プロセスが入ります。",
- "forms.description.title": "説明",
- "forms.linkedUsers.title": "リンクされたユーザー",
- "forms.linkedUsers.anotherOne": "別のユーザーを追加",
- "forms.linkedUsers.info": "作中の人は誰? リンクすることで、リンクされた人のプロフィールに作品が表示されます。",
- "forms.showcase.title": "作品紹介",
- "forms.showcase.info": "作品紹介の作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"forms.tags.title": "タグ",
"forms.tags.selectFromExisting": "既存のタグから選択する",
"forms.tags.cantFindExisting": "タグが見つからない場合:",
diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json
index 785f0e744..f6e8b0a2d 100644
--- a/locales/ja/calendar.json
+++ b/locales/ja/calendar.json
@@ -19,21 +19,14 @@
"forms.badges": "優勝賞品のバッジ",
"forms.badges.placeholder": "バッジを選択する",
"forms.mapPool": "選択可能なステージ",
- "forms.participantCount": "参加人数",
"forms.reportResultsHeader": "{{eventName}} の結果報告",
"forms.reportResultsInfo": "報告する結果の数を選択します。勝者のみ、トップ3など自由に決定できます。",
- "forms.team.add": "チームを追加",
- "forms.team.remove": "チームを削除",
- "forms.team.name": "チーム名",
"forms.team.placing": "順位",
"forms.team.player.header": "プレイヤー {{number}}",
"forms.team.player.add": "プレイヤーを追加",
"forms.team.player.remove": "プレイヤーを削除",
"forms.team.player.addAsUser": "ユーザーで追加 (推奨)",
"forms.team.player.addAsText": "テキストで追加",
- "forms.errors.uniqueTeamName": "それぞれのチームは固有の名前が必要です。",
- "forms.errors.duplicatePlayer": "ひとつのチームに同一の名前のプレイヤーは設定できません。",
- "forms.errors.emptyTeam": "それぞれのチームには最低1プレイヤーが必要です。",
"tag.desc.SPECIAL": "通常とは異なるルール(ブキ縛りなど)が適用される場合があります。",
"tag.desc.ART": "このトーナメントに参加することでイラストをもらうことができます。",
"tag.desc.MONEY": "このトーナメントに参加することで賞金をもらうことができます。",
diff --git a/locales/ja/forms.json b/locales/ja/forms.json
index 4852c4235..c5805f514 100644
--- a/locales/ja/forms.json
+++ b/locales/ja/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "そのチーム名はすでに使用されています",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "使用ブキ",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "説明",
"labels.user": "",
+ "labels.linkedUsers": "リンクされたユーザー",
+ "bottomTexts.linkedUsers": "作中の人は誰? リンクすることで、リンクされた人のプロフィールに作品が表示されます。",
+ "labels.showcase": "作品紹介",
+ "bottomTexts.showcase": "作品紹介の作品はイラストページに表示されます。ショーケースには1つの作品しか設定できません。",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "コメント",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "参加人数",
+ "labels.teams": "",
+ "labels.teamName": "チーム名",
+ "labels.placement": "順位",
+ "errors.emptyTeam": "それぞれのチームには最低1プレイヤーが必要です。",
+ "errors.duplicatePlayer": "ひとつのチームに同一の名前のプレイヤーは設定できません。",
+ "errors.uniqueTeamName": "それぞれのチームは固有の名前が必要です。",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "想い",
+ "bottomTexts.sentiment": "正、負の想いは列の中の順番を左右します。",
+ "options.sentiment.POSITIVE": "正",
+ "options.sentiment.NEUTRAL": "普通",
+ "options.sentiment.NEGATIVE": "負",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/ja/friends.json b/locales/ja/friends.json
index 710ed7c25..94a9f4972 100644
--- a/locales/ja/friends.json
+++ b/locales/ja/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/ja/q.json b/locales/ja/q.json
index 3341008a9..09b87f22e 100644
--- a/locales/ja/q.json
+++ b/locales/ja/q.json
@@ -12,12 +12,6 @@
"vc.NO": "ボイスチャット不可",
"vc.LISTEN_ONLY": "聞き専",
"privateNote.header": "{{name}}についての非公開メモ",
- "privateNote.comment.header": "コメント",
- "privateNote.sentiment.header": "想い",
- "privateNote.sentiment.info": "正、負の想いは列の中の順番を左右します。",
- "privateNote.sentiment.POSITIVE": "正",
- "privateNote.sentiment.NEUTRAL": "普通",
- "privateNote.sentiment.NEGATIVE": "負",
"privateNote.delete.header": "{{name}}についてのメモを消しますか?",
"front.cities.la": "ロサンゼルス",
"front.cities.nyc": "ニューヨーク",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/ja/scrims.json b/locales/ja/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/ja/scrims.json
+++ b/locales/ja/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/ja/user.json b/locales/ja/user.json
index a78229158..58f8efe1b 100644
--- a/locales/ja/user.json
+++ b/locales/ja/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/ko/analyzer.json b/locales/ko/analyzer.json
index c21d687a5..6707e0371 100644
--- a/locales/ko/analyzer.json
+++ b/locales/ko/analyzer.json
@@ -23,6 +23,7 @@
"stat.specialPoints": "스페셜 필요 포인트",
"stat.specialLost": "쓰러졌을 때 잃는 스페셜 포인트",
"stat.specialLostSplattedByRP": "페널티 유저에게 쓰러졌을 대 잃는 스페셜 포인트",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "사용 이후 잉크 회복 불가 시간",
"stat.subWeaponInkConsumptionPercentage": "잉크 소모 비율",
"stat.squidFormInkRecoverySeconds": "잉크 완전 회복 시간 (오징어의 모습)",
diff --git a/locales/ko/art.json b/locales/ko/art.json
index 6b365fbd1..0bb6484ba 100644
--- a/locales/ko/art.json
+++ b/locales/ko/art.json
@@ -11,12 +11,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
- "forms.description.title": "",
- "forms.linkedUsers.title": "",
- "forms.linkedUsers.anotherOne": "",
- "forms.linkedUsers.info": "",
- "forms.showcase.title": "",
- "forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",
diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json
index f57a24593..929acca6c 100644
--- a/locales/ko/calendar.json
+++ b/locales/ko/calendar.json
@@ -17,21 +17,14 @@
"forms.badges": "배지 상품",
"forms.badges.placeholder": "배지 상품을 선택하세요",
"forms.mapPool": "",
- "forms.participantCount": "참여자 수",
"forms.reportResultsHeader": "{{eventName}}의 결과 보고",
"forms.reportResultsInfo": "얼마나 많은 결과를 보고할지 선택할 수 있습니다.우승팀만일 수도 있고 탑3 또는 원하시는대로.",
- "forms.team.add": "팀 추가",
- "forms.team.remove": "팀 제거",
- "forms.team.name": "팀 이름",
"forms.team.placing": "순위",
"forms.team.player.header": "{{number}}번 플레이어",
"forms.team.player.add": "플레이어 추가",
"forms.team.player.remove": "플레이어 제거",
"forms.team.player.addAsUser": "유저로 추가 (권장)",
"forms.team.player.addAsText": "텍스트로 추가",
- "forms.errors.uniqueTeamName": "각 팀은 다른 이름을 가져야 합니다.",
- "forms.errors.duplicatePlayer": "같은 팀에 같은 선수를 두 번 넣을 수 없습니다.",
- "forms.errors.emptyTeam": "모든 팀은 최소한 한 명의 플레이어가 필요합니다.",
"tag.desc.SPECIAL": "기본과 다른 특수한 규칙 ex) 사용 가능한 무기 제한.",
"tag.desc.ART": "이 대회에 참가함으로써 그림을 받을 수 있습니다.",
"tag.desc.MONEY": "이 대회에 참가함으로써 상금을 받을 수 있습니다.",
diff --git a/locales/ko/forms.json b/locales/ko/forms.json
index 8f16d366a..1b9b16c7f 100644
--- a/locales/ko/forms.json
+++ b/locales/ko/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "설명",
"labels.user": "",
+ "labels.linkedUsers": "",
+ "bottomTexts.linkedUsers": "",
+ "labels.showcase": "",
+ "bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "참여자 수",
+ "labels.teams": "",
+ "labels.teamName": "팀 이름",
+ "labels.placement": "순위",
+ "errors.emptyTeam": "모든 팀은 최소한 한 명의 플레이어가 필요합니다.",
+ "errors.duplicatePlayer": "같은 팀에 같은 선수를 두 번 넣을 수 없습니다.",
+ "errors.uniqueTeamName": "각 팀은 다른 이름을 가져야 합니다.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/ko/friends.json b/locales/ko/friends.json
index 710ed7c25..94a9f4972 100644
--- a/locales/ko/friends.json
+++ b/locales/ko/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/ko/q.json b/locales/ko/q.json
index 4d96d5401..b5a83e632 100644
--- a/locales/ko/q.json
+++ b/locales/ko/q.json
@@ -12,12 +12,6 @@
"vc.NO": "",
"vc.LISTEN_ONLY": "",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/ko/scrims.json b/locales/ko/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/ko/scrims.json
+++ b/locales/ko/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/ko/user.json b/locales/ko/user.json
index 7bad571bf..f23c8dc72 100644
--- a/locales/ko/user.json
+++ b/locales/ko/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/nl/analyzer.json b/locales/nl/analyzer.json
index 5b7a31c95..63c528be3 100644
--- a/locales/nl/analyzer.json
+++ b/locales/nl/analyzer.json
@@ -23,6 +23,9 @@
"stat.specialPoints": "Punten tot speciale wapen",
"stat.specialLost": "Speciale punten verloren wanneer je wordt uitgeschakeld",
"stat.specialLostSplattedByRP": "",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Vertraging van inktvulling na gebruik",
"stat.subWeaponInkConsumptionPercentage": "",
"stat.squidFormInkRecoverySeconds": "Volledige hersteltijd inkttank (zwem vorm)",
diff --git a/locales/nl/art.json b/locales/nl/art.json
index 26d9f7108..0dd4a60d8 100644
--- a/locales/nl/art.json
+++ b/locales/nl/art.json
@@ -13,12 +13,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
- "forms.description.title": "",
- "forms.linkedUsers.title": "",
- "forms.linkedUsers.anotherOne": "",
- "forms.linkedUsers.info": "",
- "forms.showcase.title": "",
- "forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",
diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json
index 3f1275999..7c39f8fb0 100644
--- a/locales/nl/calendar.json
+++ b/locales/nl/calendar.json
@@ -21,21 +21,14 @@
"forms.badges": "Badge prijzen",
"forms.badges.placeholder": "Kies een badge prijs",
"forms.mapPool": "Beschikbare levels",
- "forms.participantCount": "Aantal deelnemers",
"forms.reportResultsHeader": "Uitslagen doorgeven voor {{eventName}}",
"forms.reportResultsInfo": "Je kunt zelf kiezen hoeveel uitslagen je wilt doorgeven. Het kan bijvoorbeeld alleen het winnende team zijn, maar ook de top 3, etc.",
- "forms.team.add": "Voeg team toe",
- "forms.team.remove": "Verwijder team",
- "forms.team.name": "Team naam",
"forms.team.placing": "Plaatsing",
"forms.team.player.header": "Speler {{number}}",
"forms.team.player.add": "Voeg speler toe",
"forms.team.player.remove": "Verwijder speler",
"forms.team.player.addAsUser": "Voeg toe als gebruiker (aanbevolen)",
"forms.team.player.addAsText": "Voeg toe als tekst",
- "forms.errors.uniqueTeamName": "Elk team heeft een eigen unieke naam nodig.",
- "forms.errors.duplicatePlayer": "Je kunt niet een speler twee keer in hetzelfde team neerzetten.",
- "forms.errors.emptyTeam": "Elk team moet tenminste één speler hebben",
"tag.desc.SPECIAL": "Spelregels die verschillen van de standaard regels, bijv. dat er alleen een beperkte set wapens gebruikt mogen worden.",
"tag.desc.ART": "Door in dit toernooi te spelen kun je tekeningen winnen.",
"tag.desc.MONEY": "Door in dit toernooi te spelen kun je geld winnen.",
diff --git a/locales/nl/forms.json b/locales/nl/forms.json
index 46d64bacb..88e9b39f8 100644
--- a/locales/nl/forms.json
+++ b/locales/nl/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Beschrijving",
"labels.user": "",
+ "labels.linkedUsers": "",
+ "bottomTexts.linkedUsers": "",
+ "labels.showcase": "",
+ "bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Aantal deelnemers",
+ "labels.teams": "",
+ "labels.teamName": "Team naam",
+ "labels.placement": "Plaatsing",
+ "errors.emptyTeam": "Elk team moet tenminste één speler hebben",
+ "errors.duplicatePlayer": "Je kunt niet een speler twee keer in hetzelfde team neerzetten.",
+ "errors.uniqueTeamName": "Elk team heeft een eigen unieke naam nodig.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/nl/friends.json b/locales/nl/friends.json
index bccdf5dae..b672734aa 100644
--- a/locales/nl/friends.json
+++ b/locales/nl/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/nl/q.json b/locales/nl/q.json
index 4d96d5401..b5a83e632 100644
--- a/locales/nl/q.json
+++ b/locales/nl/q.json
@@ -12,12 +12,6 @@
"vc.NO": "",
"vc.LISTEN_ONLY": "",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/nl/scrims.json b/locales/nl/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/nl/scrims.json
+++ b/locales/nl/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/nl/user.json b/locales/nl/user.json
index 0588f51d1..50fd9ae89 100644
--- a/locales/nl/user.json
+++ b/locales/nl/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/pl/analyzer.json b/locales/pl/analyzer.json
index 584d3ef3c..69c17c1ab 100644
--- a/locales/pl/analyzer.json
+++ b/locales/pl/analyzer.json
@@ -23,6 +23,11 @@
"stat.specialPoints": "Punkty do broni specjalnej",
"stat.specialLost": "Utracone punkty broni specjalnej po zabiciu",
"stat.specialLostSplattedByRP": "Utracone punkty broni specjalnej po zabiciu (Respawn Punisher)",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_few": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Brak czasu regeneracji farby po użyciu",
"stat.subWeaponInkConsumptionPercentage": "Konsumpcja zbiornika farbowego",
"stat.squidFormInkRecoverySeconds": "Czas pełnej regeneracji zbiornika farbowego (forma kalmara)",
diff --git a/locales/pl/art.json b/locales/pl/art.json
index 4d96340cd..c84017f3c 100644
--- a/locales/pl/art.json
+++ b/locales/pl/art.json
@@ -15,12 +15,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "",
- "forms.description.title": "",
- "forms.linkedUsers.title": "",
- "forms.linkedUsers.anotherOne": "",
- "forms.linkedUsers.info": "",
- "forms.showcase.title": "",
- "forms.showcase.info": "",
"forms.tags.title": "",
"forms.tags.selectFromExisting": "",
"forms.tags.cantFindExisting": "",
diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json
index 3c0b5efa3..9947845bb 100644
--- a/locales/pl/calendar.json
+++ b/locales/pl/calendar.json
@@ -25,21 +25,14 @@
"forms.badges": "Odznaki",
"forms.badges.placeholder": "Wybierz odznakę",
"forms.mapPool": "Pula map",
- "forms.participantCount": "Ilość osób biorących udział",
"forms.reportResultsHeader": "Zgłaszanie wyników dla {{eventName}}",
"forms.reportResultsInfo": "Możesz wybrać ile zgłosić wyników. Mogą to być wyniki drużyny zwyciężonej, Top 3 lub ile chcesz.",
- "forms.team.add": "Dodaj drużynę",
- "forms.team.remove": "Usuń drużynę",
- "forms.team.name": "Nazwa drużyny",
"forms.team.placing": "Miejsce",
"forms.team.player.header": "Gracz {{number}}",
"forms.team.player.add": "Dodaj gracza",
"forms.team.player.remove": "Usuń gracza",
"forms.team.player.addAsUser": "Dodaj jako użytkownik (preferowane)",
"forms.team.player.addAsText": "Dodaj jako tekst",
- "forms.errors.uniqueTeamName": "Każda drużyna musi mieć unikatową nazwę.",
- "forms.errors.duplicatePlayer": "Nie można mieć takiej samej nazwy członka dwa razy w jednej drużynie.",
- "forms.errors.emptyTeam": "Każda drużyna musi mieć przynajmniej jednego członka.",
"tag.desc.SPECIAL": "Zasady różniące się od standardowych np. limit dozwolonych broni mogą być użyte.",
"tag.desc.ART": "Grając w tym turnieju możesz wygrać rysunek/sztukę.",
"tag.desc.MONEY": "Grając w tym turnieju możesz wygrać nagrodę pieniężną.",
diff --git a/locales/pl/forms.json b/locales/pl/forms.json
index 5e5b79066..bcb6af0b6 100644
--- a/locales/pl/forms.json
+++ b/locales/pl/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Istnieje już drużyna o tym imieniu",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Pula broni",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Opis",
"labels.user": "",
+ "labels.linkedUsers": "",
+ "bottomTexts.linkedUsers": "",
+ "labels.showcase": "",
+ "bottomTexts.showcase": "",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
"labels.comment": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Ilość osób biorących udział",
+ "labels.teams": "",
+ "labels.teamName": "Nazwa drużyny",
+ "labels.placement": "Miejsce",
+ "errors.emptyTeam": "Każda drużyna musi mieć przynajmniej jednego członka.",
+ "errors.duplicatePlayer": "Nie można mieć takiej samej nazwy członka dwa razy w jednej drużynie.",
+ "errors.uniqueTeamName": "Każda drużyna musi mieć unikatową nazwę.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "",
+ "bottomTexts.sentiment": "",
+ "options.sentiment.POSITIVE": "",
+ "options.sentiment.NEUTRAL": "",
+ "options.sentiment.NEGATIVE": "",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/pl/friends.json b/locales/pl/friends.json
index 7aae6e996..fa6b96ad3 100644
--- a/locales/pl/friends.json
+++ b/locales/pl/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/pl/q.json b/locales/pl/q.json
index 4d96d5401..b5a83e632 100644
--- a/locales/pl/q.json
+++ b/locales/pl/q.json
@@ -12,12 +12,6 @@
"vc.NO": "",
"vc.LISTEN_ONLY": "",
"privateNote.header": "",
- "privateNote.comment.header": "",
- "privateNote.sentiment.header": "",
- "privateNote.sentiment.info": "",
- "privateNote.sentiment.POSITIVE": "",
- "privateNote.sentiment.NEUTRAL": "",
- "privateNote.sentiment.NEGATIVE": "",
"privateNote.delete.header": "",
"front.cities.la": "",
"front.cities.nyc": "",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/pl/scrims.json b/locales/pl/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/pl/scrims.json
+++ b/locales/pl/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/pl/user.json b/locales/pl/user.json
index dbb381e3c..bf508388c 100644
--- a/locales/pl/user.json
+++ b/locales/pl/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/pt-BR/analyzer.json b/locales/pt-BR/analyzer.json
index 98fa2655d..470d5bfc4 100644
--- a/locales/pt-BR/analyzer.json
+++ b/locales/pt-BR/analyzer.json
@@ -23,6 +23,10 @@
"stat.specialPoints": "Pontos para o especial",
"stat.specialLost": "Especial perdido quando eliminado(a)",
"stat.specialLostSplattedByRP": "Especial perdido quando eliminado(a) por usuário de RP",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Tempo sem recarga de tinta após o uso",
"stat.subWeaponInkConsumptionPercentage": "Consumo do tanque de tinta",
"stat.squidFormInkRecoverySeconds": "Tempo de recarga completa do tanque de tinta (forma de lula/polvo)",
diff --git a/locales/pt-BR/art.json b/locales/pt-BR/art.json
index 9fd086fdc..7955e45bc 100644
--- a/locales/pt-BR/art.json
+++ b/locales/pt-BR/art.json
@@ -14,12 +14,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Algumas coisas para lembrar: 1) Só faça upload de arte que envolva Splatoon 2) Só faça o upload de arte que você mesmo(a) fez 3) Sem arte +18. Há um processo de validação antes da arte ser mostrada para outros usuários.",
- "forms.description.title": "Descrição",
- "forms.linkedUsers.title": "Usuários conectados",
- "forms.linkedUsers.anotherOne": "Outro",
- "forms.linkedUsers.info": "Quem está na arte? Conectar usuários permite que sua arte apareça no perfil deles(as).",
- "forms.showcase.title": "Destaque",
- "forms.showcase.info": "Seu destaque é mostrado na página comum /art. Apenas um exemplar pode ser seu destaque de cada vez.",
"forms.tags.title": "Marcações",
"forms.tags.selectFromExisting": "Selecionar de marcações existentes",
"forms.tags.cantFindExisting": "Não consegue encontrar uma marcação existente?",
diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json
index a13dfa114..fa3deb98c 100644
--- a/locales/pt-BR/calendar.json
+++ b/locales/pt-BR/calendar.json
@@ -23,21 +23,14 @@
"forms.badges": "Prêmio(s) de insígnia",
"forms.badges.placeholder": "Escolha um prêmio de insígnia",
"forms.mapPool": "Conjunto de mapas",
- "forms.participantCount": "Contagem de participantes",
"forms.reportResultsHeader": "Declarando resultados para o(a) {{eventName}}",
"forms.reportResultsInfo": "Você decide quantos resultados declarar. Pode ser apenas o time vencedor, Top 3 ou qualquer outro tipo de placar que você quiser.",
- "forms.team.add": "Adicionar time",
- "forms.team.remove": "Remover time",
- "forms.team.name": "Nome do time",
"forms.team.placing": "Placar",
"forms.team.player.header": "Jogador {{number}}",
"forms.team.player.add": "Adicionar jogador",
"forms.team.player.remove": "Remover jogador",
"forms.team.player.addAsUser": "Adicionar como usuário (recomendado, resultado no perfil)",
"forms.team.player.addAsText": "Adicionar como texto simples (resultado apenas aqui)",
- "forms.errors.uniqueTeamName": "Cada time precisa de um nome único.",
- "forms.errors.duplicatePlayer": "Não é possível ter o mesmo jogador duas vezes no mesmo time.",
- "forms.errors.emptyTeam": "Cada time deve ter pelo menos um jogador.",
"tag.desc.SPECIAL": "Conjunto de regras que diferem do padrão. Ex. limita que armas podem ser usadas.",
"tag.desc.ART": "Você pode ganhar arte por participar desse torneio.",
"tag.desc.MONEY": "Você pode ganhar dinheiro por participar desse torneio.",
diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json
index 8bd92f934..4f6092620 100644
--- a/locales/pt-BR/forms.json
+++ b/locales/pt-BR/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Já existe um time com esse nome",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Seleção de armas",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Descrição",
"labels.user": "",
+ "labels.linkedUsers": "Usuários conectados",
+ "bottomTexts.linkedUsers": "Quem está na arte? Conectar usuários permite que sua arte apareça no perfil deles(as).",
+ "labels.showcase": "Destaque",
+ "bottomTexts.showcase": "Seu destaque é mostrado na página comum /art. Apenas um exemplar pode ser seu destaque de cada vez.",
"labels.orgMemberRole": "",
"labels.orgMemberRoleDisplayName": "",
"labels.orgSocialLinks": "",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Comentário",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Contagem de participantes",
+ "labels.teams": "",
+ "labels.teamName": "Nome do time",
+ "labels.placement": "Placar",
+ "errors.emptyTeam": "Cada time deve ter pelo menos um jogador.",
+ "errors.duplicatePlayer": "Não é possível ter o mesmo jogador duas vezes no mesmo time.",
+ "errors.uniqueTeamName": "Cada time precisa de um nome único.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Sentimento",
+ "bottomTexts.sentiment": "O sentimento positivo ou negativo afeta a separação ou junção dele(a) de você na fila",
+ "options.sentiment.POSITIVE": "Positivo",
+ "options.sentiment.NEUTRAL": "Neutro",
+ "options.sentiment.NEGATIVE": "Negativo",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/pt-BR/friends.json b/locales/pt-BR/friends.json
index 38898f201..fbce6b594 100644
--- a/locales/pt-BR/friends.json
+++ b/locales/pt-BR/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json
index 9dcece567..988ee5765 100644
--- a/locales/pt-BR/q.json
+++ b/locales/pt-BR/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Não pode participar do chat de voz",
"vc.LISTEN_ONLY": "Pode apenas ouvir",
"privateNote.header": "Nota privada sobre {{name}}",
- "privateNote.comment.header": "Comentário",
- "privateNote.sentiment.header": "Sentimento",
- "privateNote.sentiment.info": "O sentimento positivo ou negativo afeta a separação ou junção dele(a) de você na fila",
- "privateNote.sentiment.POSITIVE": "Positivo",
- "privateNote.sentiment.NEUTRAL": "Neutro",
- "privateNote.sentiment.NEGATIVE": "Negativo",
"privateNote.delete.header": "Apagar a sua nota sobre {{name}}?",
"front.cities.la": "Los Angeles",
"front.cities.nyc": "Nova Iorque",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/pt-BR/scrims.json b/locales/pt-BR/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/pt-BR/scrims.json
+++ b/locales/pt-BR/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json
index 579d9c5cd..5828e6378 100644
--- a/locales/pt-BR/user.json
+++ b/locales/pt-BR/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/ru/analyzer.json b/locales/ru/analyzer.json
index 13fe32f5f..9685e6dcf 100644
--- a/locales/ru/analyzer.json
+++ b/locales/ru/analyzer.json
@@ -23,6 +23,11 @@
"stat.specialPoints": "Очки для использования",
"stat.specialLost": "Потеря заряда спешала после плюха",
"stat.specialLostSplattedByRP": "Потеря заряда спешала после плюха (плюхнут игроком с Карой)",
+ "stat.tenacitySecondsToSpecial_one": "",
+ "stat.tenacitySecondsToSpecial_few": "",
+ "stat.tenacitySecondsToSpecial_many": "",
+ "stat.tenacitySecondsToSpecial_other": "",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "Время до начала восстановления краски после использования",
"stat.subWeaponInkConsumptionPercentage": "Потребление чернил",
"stat.squidFormInkRecoverySeconds": "Время до полного восстановления баллона (в краске)",
diff --git a/locales/ru/art.json b/locales/ru/art.json
index 14dd7f67f..6023adeb0 100644
--- a/locales/ru/art.json
+++ b/locales/ru/art.json
@@ -15,12 +15,6 @@
"tabs.recentlyUploaded": "",
"tabs.showcase": "",
"forms.caveats": "Примите к сведению: 1) Загружайте только арты по Splatoon 2) Загружайте только арты, созданные вами 3) NSFW запрещено. Перед тем как арт будет показан другим пользователям, он пройдёт процесс подтверждения модераторами.",
- "forms.description.title": "Описание",
- "forms.linkedUsers.title": "Отмеченный пользователь",
- "forms.linkedUsers.anotherOne": "Добавить",
- "forms.linkedUsers.info": "Кто изображен на арте? В профиле отмеченного пользователя будет отображен ваш арт.",
- "forms.showcase.title": "Закрепить",
- "forms.showcase.info": "Закреплённый арт будет показан на общей /art странице. Одновременно может быть закреплён только один арт.",
"forms.tags.title": "Теги",
"forms.tags.selectFromExisting": "Выбрать существующий тег",
"forms.tags.cantFindExisting": "Не можете найти существующий тег?",
diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json
index 5e1dbca53..19109edd2 100644
--- a/locales/ru/calendar.json
+++ b/locales/ru/calendar.json
@@ -25,21 +25,14 @@
"forms.badges": "Призовые значки",
"forms.badges.placeholder": "Выберите призовой значок",
"forms.mapPool": "Список карт",
- "forms.participantCount": "Количество участников",
"forms.reportResultsHeader": "Указать результаты для {{eventName}}",
"forms.reportResultsInfo": "Вы можете указать столько результатов, сколько сочтёте нужным. Это может быть команда победителей, топ-3 или как вы сами захотите.",
- "forms.team.add": "Добавить команду",
- "forms.team.remove": "Убрать команду",
- "forms.team.name": "Название команды",
"forms.team.placing": "Место",
"forms.team.player.header": "Игрок {{number}}",
"forms.team.player.add": "Добавить игрока",
"forms.team.player.remove": "Убрать игрока",
"forms.team.player.addAsUser": "Добавить как пользователя (рекомендуется)",
"forms.team.player.addAsText": "Добавить как текст",
- "forms.errors.uniqueTeamName": "У каждой команды должно быть уникальное имя.",
- "forms.errors.duplicatePlayer": "Нельзя иметь одного и того же участника дважды в одной команде.",
- "forms.errors.emptyTeam": "Каждая команда должна иметь как минимум одного игрока.",
"tag.desc.SPECIAL": "Набор правил, отличающийся от стандартных — например, ограниченный пул оружия.",
"tag.desc.ART": "Играя в этом турнире вы можете выиграть арт.",
"tag.desc.MONEY": "Играя в этом турнире вы можете выиграть денежный приз.",
diff --git a/locales/ru/forms.json b/locales/ru/forms.json
index a8912aaf7..71960c3dd 100644
--- a/locales/ru/forms.json
+++ b/locales/ru/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "",
"errors.notAllowedCharacters": "",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "",
"errors.duplicateName": "Уже существует команда с таким названием",
"errors.duplicateOrgName": "",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "",
"labels.weaponPool": "Используемое оружие",
"placeholders.weaponPoolFull": "",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "",
"labels.languages": "",
"options.voiceChat.yes": "",
@@ -147,6 +149,10 @@
"labels.urls": "",
"labels.description": "Описание",
"labels.user": "Пользователь",
+ "labels.linkedUsers": "Отмеченный пользователь",
+ "bottomTexts.linkedUsers": "Кто изображен на арте? В профиле отмеченного пользователя будет отображен ваш арт.",
+ "labels.showcase": "Закрепить",
+ "bottomTexts.showcase": "Закреплённый арт будет показан на общей /art странице. Одновременно может быть закреплён только один арт.",
"labels.orgMemberRole": "Роль",
"labels.orgMemberRoleDisplayName": "Отображаемое название роли",
"labels.orgSocialLinks": "Соц. ссылки",
@@ -248,9 +254,14 @@
"options.artSource.ALL": "",
"options.artSource.MADE-BY": "",
"options.artSource.MADE-OF": "",
+ "labels.controller": "",
+ "options.controller.s1-pro-con": "",
+ "options.controller.s2-pro-con": "",
+ "options.controller.grip": "",
+ "options.controller.handheld": "",
"labels.tierListUrl": "",
"labels.plusTier": "",
- "labels.comment": "",
+ "labels.comment": "Комментарий",
"errors.plusAlreadySuggested": "",
"errors.plusAlreadyMember": "",
"errors.plusCannotSuggest": "",
@@ -380,5 +391,34 @@
"errors.allModePool": "",
"errors.bracketUrlRequired": "",
"errors.bracketProgressionRequired": "",
- "errors.maxMembersRange": ""
+ "errors.maxMembersRange": "",
+ "labels.participantCount": "Количество участников",
+ "labels.teams": "",
+ "labels.teamName": "Название команды",
+ "labels.placement": "Место",
+ "errors.emptyTeam": "Каждая команда должна иметь как минимум одного игрока.",
+ "errors.duplicatePlayer": "Нельзя иметь одного и того же участника дважды в одной команде.",
+ "errors.uniqueTeamName": "У каждой команды должно быть уникальное имя.",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "Мнение",
+ "bottomTexts.sentiment": "Положительное или отрицательное мнение повлияет на их сортировку в очереди только для вас",
+ "options.sentiment.POSITIVE": "Положительное",
+ "options.sentiment.NEUTRAL": "Нейтральное",
+ "options.sentiment.NEGATIVE": "Отрицательное",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/ru/friends.json b/locales/ru/friends.json
index 7aae6e996..fa6b96ad3 100644
--- a/locales/ru/friends.json
+++ b/locales/ru/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "",
"friendsList.viewMatch": "",
"friendsList.live": "",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "",
"friendsList.deleteFriend": "",
"friendsList.deleteConfirm": "",
diff --git a/locales/ru/q.json b/locales/ru/q.json
index d6e9824c3..780ad5bd4 100644
--- a/locales/ru/q.json
+++ b/locales/ru/q.json
@@ -12,12 +12,6 @@
"vc.NO": "Не может в войс",
"vc.LISTEN_ONLY": "Может только слушать",
"privateNote.header": "Личная заметка о {{name}}",
- "privateNote.comment.header": "Комментарий",
- "privateNote.sentiment.header": "Мнение",
- "privateNote.sentiment.info": "Положительное или отрицательное мнение повлияет на их сортировку в очереди только для вас",
- "privateNote.sentiment.POSITIVE": "Положительное",
- "privateNote.sentiment.NEUTRAL": "Нейтральное",
- "privateNote.sentiment.NEGATIVE": "Отрицательное",
"privateNote.delete.header": "Удалить заметку о {{name}}?",
"front.cities.la": "Лос-Анджелес",
"front.cities.nyc": "Нью-Йорк",
@@ -174,7 +168,7 @@
"match.timeline.loss": "",
"match.timeline.out": "",
"match.timeline.in": "",
- "match.timeline.live": "",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "",
"match.timeline.explainer.picked": "",
"match.timeline.explainer.pick": "",
diff --git a/locales/ru/scrims.json b/locales/ru/scrims.json
index 37ce81555..9c1ee23f1 100644
--- a/locales/ru/scrims.json
+++ b/locales/ru/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "",
"forms.maps.allModes": "",
"forms.maps.tournament": "",
- "forms.mapsTournament.title": "",
"page.scheduledScrim": "",
"page.vs": "",
"associations.title": "",
diff --git a/locales/ru/user.json b/locales/ru/user.json
index 42ff42d70..1302b2702 100644
--- a/locales/ru/user.json
+++ b/locales/ru/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "",
"widgets.forms.weapon": "",
"widgets.forms.peakXp": "",
- "widgets.forms.controller": "",
"widgets.forms.source": "",
"widgets.forms.source.ALL": "",
"widgets.forms.source.MADE-BY": "",
diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json
index 12b321e45..d44f839b8 100644
--- a/locales/zh/analyzer.json
+++ b/locales/zh/analyzer.json
@@ -23,6 +23,7 @@
"stat.specialPoints": "特殊武器充能点数",
"stat.specialLost": "阵亡时的特殊武器点数损失",
"stat.specialLostSplattedByRP": "被死惩使用者击倒时的特殊武器点数损失",
+ "stat.tenacitySecondsToSpecial.explanation": "",
"stat.whiteInk": "使用后回墨间隔",
"stat.subWeaponInkConsumptionPercentage": "耗墨量",
"stat.squidFormInkRecoverySeconds": "完全回墨时间 (鱿鱼形态)",
diff --git a/locales/zh/art.json b/locales/zh/art.json
index 7e7bce4d3..e9c88365f 100644
--- a/locales/zh/art.json
+++ b/locales/zh/art.json
@@ -12,12 +12,6 @@
"tabs.recentlyUploaded": "最近上传",
"tabs.showcase": "展示区",
"forms.caveats": "请注意:1) 仅上传与《斯普拉遁》相关的作品 2) 仅上传由您亲自创作的作品 3) 禁止 NSFW 作品。作品经过审核后才对其他用户可见。",
- "forms.description.title": "描述",
- "forms.linkedUsers.title": "关联用户",
- "forms.linkedUsers.anotherOne": "另一个",
- "forms.linkedUsers.info": "作品与谁有关?关联用户可以让您的作品显示在对方的个人资料中。",
- "forms.showcase.title": "展示区",
- "forms.showcase.info": "您的展示作品将会显示在插画的主页面。只能将一个作品设置为展示作品。",
"forms.tags.title": "标签",
"forms.tags.selectFromExisting": "从已有标签中选择",
"forms.tags.cantFindExisting": "找不到已有标签?",
diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json
index c4886a0c2..088ad6c36 100644
--- a/locales/zh/calendar.json
+++ b/locales/zh/calendar.json
@@ -19,21 +19,14 @@
"forms.badges": "徽章奖励",
"forms.badges.placeholder": "选择一项徽章奖励",
"forms.mapPool": "场地池",
- "forms.participantCount": "参赛者数量",
"forms.reportResultsHeader": "汇报 {{eventName}} 的结果",
"forms.reportResultsInfo": "您可以自定义要显示的赛事结果。可以选择仅冠军队伍、前三名或其他结果。",
- "forms.team.add": "添加队伍",
- "forms.team.remove": "移除队伍",
- "forms.team.name": "队伍名称",
"forms.team.placing": "排名",
"forms.team.player.header": "{{number}} 号选手",
"forms.team.player.add": "添加选手",
"forms.team.player.remove": "移除选手",
"forms.team.player.addAsUser": "添加为用户(推荐)",
"forms.team.player.addAsText": "添加为文本",
- "forms.errors.uniqueTeamName": "每队队名不得重复。",
- "forms.errors.duplicatePlayer": "同一队伍内选手不得重复。",
- "forms.errors.emptyTeam": "每队至少要有一名选手。",
"tag.desc.SPECIAL": "特殊规则,例如: 限定可用武器。",
"tag.desc.ART": "参加本次赛事可以赢得插画。",
"tag.desc.MONEY": "参加本次赛事可以赢得奖金。",
diff --git a/locales/zh/forms.json b/locales/zh/forms.json
index 890507437..6855fa076 100644
--- a/locales/zh/forms.json
+++ b/locales/zh/forms.json
@@ -63,6 +63,7 @@
"errors.matchNotFound": "",
"errors.invalidUrl": "请输入有效的 URL 地址",
"errors.notAllowedCharacters": "包含非法字符",
+ "errors.imageTooLarge": "",
"errors.atLeastOneOption": "请至少选择一个选项",
"errors.duplicateName": "该队伍名称已被占用",
"errors.duplicateOrgName": "该组织名称已被占用",
@@ -70,6 +71,7 @@
"errors.customRoleRequired": "请输入自定义职责的名称",
"labels.weaponPool": "武器池",
"placeholders.weaponPoolFull": "武器池已满。请移除一个武器以添加新武器",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "可以进行语音聊天吗?",
"labels.languages": "您的语言",
"options.voiceChat.yes": "是",
@@ -147,6 +149,10 @@
"labels.urls": "URL",
"labels.description": "简介",
"labels.user": "用户",
+ "labels.linkedUsers": "关联用户",
+ "bottomTexts.linkedUsers": "作品与谁有关?关联用户可以让您的作品显示在对方的个人资料中。",
+ "labels.showcase": "展示区",
+ "bottomTexts.showcase": "您的展示作品将会显示在插画的主页面。只能将一个作品设置为展示作品。",
"labels.orgMemberRole": "职位",
"labels.orgMemberRoleDisplayName": "职位显示名称",
"labels.orgSocialLinks": "社交媒体链接",
@@ -248,6 +254,11 @@
"options.artSource.ALL": "全部",
"options.artSource.MADE-BY": "由我创作",
"options.artSource.MADE-OF": "与我相关",
+ "labels.controller": "首选控制器",
+ "options.controller.s1-pro-con": "Nintendo Switch Pro 控制器",
+ "options.controller.s2-pro-con": "Nintendo Switch 2 Pro 控制器",
+ "options.controller.grip": "Joy-Con 握把",
+ "options.controller.handheld": "手提模式",
"labels.tierListUrl": "自制强度榜 URL",
"labels.plusTier": "级别",
"labels.comment": "评论",
@@ -380,5 +391,34 @@
"errors.allModePool": "如果使用“由队伍预选 - 所有模式”,场地池必须包含可用于每个蛮颓比赛模式的场地",
"errors.bracketUrlRequired": "必须填写对战表 URL",
"errors.bracketProgressionRequired": "必须为赛事设置对战表晋级规则",
- "errors.maxMembersRange": "队伍人数上限必须介于 4 到 10 人之间"
+ "errors.maxMembersRange": "队伍人数上限必须介于 4 到 10 人之间",
+ "labels.participantCount": "参赛者数量",
+ "labels.teams": "",
+ "labels.teamName": "队伍名称",
+ "labels.placement": "排名",
+ "errors.emptyTeam": "每队至少要有一名选手。",
+ "errors.duplicatePlayer": "同一队伍内选手不得重复。",
+ "errors.uniqueTeamName": "每队队名不得重复。",
+ "errors.numberOutOfRange": "",
+ "labels.sentiment": "评价",
+ "bottomTexts.sentiment": "正面或负面评价会影响对方在您的匹配列表中的排序",
+ "options.sentiment.POSITIVE": "正面",
+ "options.sentiment.NEUTRAL": "中立",
+ "options.sentiment.NEGATIVE": "负面",
+ "labels.adminOldUser": "",
+ "labels.adminNewUser": "",
+ "labels.adminPlayerId": "",
+ "labels.friendCode": "",
+ "labels.patronTier": "",
+ "labels.patronExpiresAt": "",
+ "labels.reason": "",
+ "bottomTexts.adminMigrateNewUser": "",
+ "errors.invalidFriendCode": "",
+ "options.patronTier.1": "",
+ "options.patronTier.2": "",
+ "options.patronTier.3": "",
+ "placeholders.friendCode": "",
+ "unsavedChanges.title": "",
+ "unsavedChanges.body": "",
+ "unsavedChanges.discard": ""
}
diff --git a/locales/zh/friends.json b/locales/zh/friends.json
index 61cb33325..09060c07f 100644
--- a/locales/zh/friends.json
+++ b/locales/zh/friends.json
@@ -10,6 +10,9 @@
"friendsList.viewTournament": "查看赛事",
"friendsList.viewMatch": "查看对局",
"friendsList.live": "直播中",
+ "friendsList.inMatch": "",
+ "friendsList.nextMatch": "",
+ "friendsList.watchStream": "",
"friendsList.joinSendouQ": "加入 SendouQ",
"friendsList.deleteFriend": "删除好友",
"friendsList.deleteConfirm": "确定要删除好友 {{name}} 吗?",
diff --git a/locales/zh/q.json b/locales/zh/q.json
index 00e243b3d..341a863d8 100644
--- a/locales/zh/q.json
+++ b/locales/zh/q.json
@@ -12,12 +12,6 @@
"vc.NO": "无法进行语音聊天",
"vc.LISTEN_ONLY": "仅收听",
"privateNote.header": "关于 {{name}} 的私人备注",
- "privateNote.comment.header": "评论",
- "privateNote.sentiment.header": "评价",
- "privateNote.sentiment.info": "正面或负面评价会影响对方在您的匹配列表中的排序",
- "privateNote.sentiment.POSITIVE": "正面",
- "privateNote.sentiment.NEUTRAL": "中立",
- "privateNote.sentiment.NEGATIVE": "负面",
"privateNote.delete.header": "要删除关于 {{name}} 的备注吗?",
"front.cities.la": "洛杉矶",
"front.cities.nyc": "纽约",
@@ -174,7 +168,7 @@
"match.timeline.loss": "负",
"match.timeline.out": "下场",
"match.timeline.in": "上场",
- "match.timeline.live": "直播中",
+ "match.timeline.ongoing": "",
"match.timeline.picked": "已选",
"match.timeline.explainer.picked": "此场地由该队伍选择",
"match.timeline.explainer.pick": "已选场地或模式",
diff --git a/locales/zh/scrims.json b/locales/zh/scrims.json
index 4656d2a89..57164229c 100644
--- a/locales/zh/scrims.json
+++ b/locales/zh/scrims.json
@@ -72,7 +72,6 @@
"forms.maps.rankedOnly": "仅限蛮颓比赛模式",
"forms.maps.allModes": "全部模式",
"forms.maps.tournament": "赛事...",
- "forms.mapsTournament.title": "赛事",
"page.scheduledScrim": "已安排到日程的对抗战",
"page.vs": "对战 {{opponent}}",
"associations.title": "群组",
diff --git a/locales/zh/user.json b/locales/zh/user.json
index 10f3861bd..5a088d7fc 100644
--- a/locales/zh/user.json
+++ b/locales/zh/user.json
@@ -125,7 +125,6 @@
"widgets.forms.favoriteStage": "喜爱的场地",
"widgets.forms.weapon": "武器",
"widgets.forms.peakXp": "最高 XP",
- "widgets.forms.controller": "首选控制器",
"widgets.forms.source": "作品来源",
"widgets.forms.source.ALL": "全部",
"widgets.forms.source.MADE-BY": "由我创作",
diff --git a/package.json b/package.json
index dbdb5a8da..2df29ef39 100644
--- a/package.json
+++ b/package.json
@@ -39,8 +39,8 @@
"knip": "knip"
},
"dependencies": {
- "@aws-sdk/client-s3": "3.1088.0",
- "@aws-sdk/lib-storage": "3.1088.0",
+ "@aws-sdk/client-s3": "3.1091.0",
+ "@aws-sdk/lib-storage": "3.1091.0",
"@date-fns/tz": "1.5.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
@@ -51,11 +51,10 @@
"@internationalized/date": "3.12.2",
"@react-router/node": "8.2.0",
"@react-router/serve": "8.1.0",
- "@remix-run/form-data-parser": "0.17.4",
- "@sentry/react-router": "10.65.0",
+ "@sentry/react-router": "10.67.0",
"@tldraw/tldraw": "3.12.1",
"@zumer/snapdom": "2.16.0",
- "better-sqlite3": "12.11.1",
+ "better-sqlite3": "13.0.1",
"chart.js": "4.5.1",
"clsx": "2.1.1",
"compressorjs": "1.3.0",
@@ -69,14 +68,14 @@
"isbot": "5.2.1",
"jsoncrush": "1.1.8",
"kysely": "0.29.0",
- "lucide-react": "1.24.0",
- "markdown-to-jsx": "9.8.2",
- "nanoid": "5.1.16",
+ "lucide-react": "1.25.0",
+ "markdown-to-jsx": "9.9.0",
+ "nanoid": "6.0.0",
"neverthrow": "8.2.0",
"node-cron": "4.6.0",
"nprogress": "0.2.0",
"openskill": "5.0.1",
- "p-limit": "7.3.0",
+ "p-limit": "7.3.1",
"partysocket": "1.3.0",
"qrcode.react": "4.2.0",
"react": "19.2.7",
@@ -100,7 +99,7 @@
},
"devDependencies": {
"@babel/preset-typescript": "7.29.7",
- "@biomejs/biome": "2.5.4",
+ "@biomejs/biome": "2.5.5",
"@playwright/test": "1.61.1",
"@react-router/dev": "8.2.0",
"@types/better-sqlite3": "7.6.13",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5f1480c18..68ffe181f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,11 +13,11 @@ importers:
.:
dependencies:
'@aws-sdk/client-s3':
- specifier: 3.1088.0
- version: 3.1088.0
+ specifier: 3.1091.0
+ version: 3.1091.0
'@aws-sdk/lib-storage':
- specifier: 3.1088.0
- version: 3.1088.0(@aws-sdk/client-s3@3.1088.0)
+ specifier: 3.1091.0
+ version: 3.1091.0(@aws-sdk/client-s3@3.1091.0)
'@date-fns/tz':
specifier: 1.5.0
version: 1.5.0
@@ -48,12 +48,9 @@ importers:
'@react-router/serve':
specifier: 8.1.0
version: 8.1.0(patch_hash=4d79b209948d4237f1ca3a5cca417c6d11519d8b1ab7084c75c46a39e3ac27d6)(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)
- '@remix-run/form-data-parser':
- specifier: 0.17.4
- version: 0.17.4
'@sentry/react-router':
- specifier: 10.65.0
- version: 10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
+ specifier: 10.67.0
+ version: 10.67.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)
'@tldraw/tldraw':
specifier: 3.12.1
version: 3.12.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -61,8 +58,8 @@ importers:
specifier: 2.16.0
version: 2.16.0
better-sqlite3:
- specifier: 12.11.1
- version: 12.11.1
+ specifier: 13.0.1
+ version: 13.0.1
chart.js:
specifier: 4.5.1
version: 4.5.1
@@ -103,14 +100,14 @@ importers:
specifier: 0.29.0
version: 0.29.0(patch_hash=6f395b25414c1ef852485fa3a03d2d521816064697d8c4bff337e2b24d19daa6)
lucide-react:
- specifier: 1.24.0
- version: 1.24.0(react@19.2.7)
+ specifier: 1.25.0
+ version: 1.25.0(react@19.2.7)
markdown-to-jsx:
- specifier: 9.8.2
- version: 9.8.2(react@19.2.7)
+ specifier: 9.9.0
+ version: 9.9.0(react@19.2.7)
nanoid:
- specifier: 5.1.16
- version: 5.1.16
+ specifier: 6.0.0
+ version: 6.0.0
neverthrow:
specifier: 8.2.0
version: 8.2.0
@@ -124,8 +121,8 @@ importers:
specifier: 5.0.1
version: 5.0.1
p-limit:
- specifier: 7.3.0
- version: 7.3.0
+ specifier: 7.3.1
+ version: 7.3.1
partysocket:
specifier: 1.3.0
version: 1.3.0(react@19.2.7)
@@ -191,8 +188,8 @@ importers:
specifier: 7.29.7
version: 7.29.7(@babel/core@7.29.7)
'@biomejs/biome':
- specifier: 2.5.4
- version: 2.5.4
+ specifier: 2.5.5
+ version: 2.5.5
'@playwright/test':
specifier: 1.61.1
version: 1.61.1
@@ -262,89 +259,89 @@ importers:
packages:
- '@apm-js-collab/code-transformer-bundler-plugins@0.5.0':
- resolution: {integrity: sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==}
+ '@apm-js-collab/code-transformer-bundler-plugins@0.7.3':
+ resolution: {integrity: sha512-qNbPwuMZ8f5ZuGj/ttPeB7a6C/S1bB6tNYaEL5vNiRKydSAxa4AU0gxCWgaP4fVju+AuwhcumSFjrEcGF9Dv7Q==}
engines: {node: '>=18.0.0'}
- '@apm-js-collab/code-transformer@0.15.0':
- resolution: {integrity: sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==}
+ '@apm-js-collab/code-transformer@0.18.1':
+ resolution: {integrity: sha512-u1Hb6bHjWtkSpiprwVP6YaHC1DTN4RAU3zYkUDUe7WMnJwdyU1pwTL9dFKiSJB9IiLue/EQovmyx6xhU7FFtAQ==}
hasBin: true
- '@apm-js-collab/tracing-hooks@0.10.1':
- resolution: {integrity: sha512-w2OWXR7FWrKqSziuE9+QclaZrStxO/8+OwbXM635s/zs0Eez1Qo3ivSPdB2WsaPY/iznKTytONPx/PitD7IXcA==}
+ '@apm-js-collab/tracing-hooks@0.13.0':
+ resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==}
- '@aws-sdk/checksums@3.1000.19':
- resolution: {integrity: sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==}
+ '@aws-sdk/checksums@3.1000.21':
+ resolution: {integrity: sha512-AcYTunavv8dqm9u2pbq/gIlFERUo+jQDKKLZpYOgMLLytoAJ/qoyuhWj97FB7UzpMFVJNzMEUylzKK/s/05org==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-s3@3.1088.0':
- resolution: {integrity: sha512-9ryKKxnjtJRKLDI24P+2gQZki7k28BeV6gz1AySvFDWayhjAZuh4QZjCyx55tqR8Oz0IJxWutgJRO43dDOcXoQ==}
+ '@aws-sdk/client-s3@3.1091.0':
+ resolution: {integrity: sha512-jvXBCWZdPEXACF7TEbLdwLgmrcWvAqSnIJeRbnnFTxLuPRAZtEbujVUU48i7PGnrOoO6Jc6HCw9hRqBECoKSww==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/core@3.976.0':
- resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==}
+ '@aws-sdk/core@3.977.1':
+ resolution: {integrity: sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-env@3.972.60':
- resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==}
+ '@aws-sdk/credential-provider-env@3.972.62':
+ resolution: {integrity: sha512-BkDrk2cNjed31IKin/Oksb2ziF+gfuyRskFVuT4EU9Mep7M8Y/d8DJG4+anHme4Vuse7CwaEscwEfGyR6mzBhQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-http@3.972.62':
- resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==}
+ '@aws-sdk/credential-provider-http@3.972.64':
+ resolution: {integrity: sha512-Wj1FGK2IxY5EccQCvH+niTYhIvDoDujJf2CpRRgS3NpYNEgiFNVItNbJYQjINRlu7fG7jSsXkKV0UWKriEplrw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-ini@3.973.5':
- resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==}
+ '@aws-sdk/credential-provider-ini@3.973.7':
+ resolution: {integrity: sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-login@3.972.67':
- resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==}
+ '@aws-sdk/credential-provider-login@3.972.69':
+ resolution: {integrity: sha512-gM3j0Ie9+FoLNTYODY+QWbg3vCRBc7mR9cRdntxTMkFYIrwfRmuucfavP6HNBlYSuaYww54TNJGej4GFgoPZAg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-node@3.972.71':
- resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==}
+ '@aws-sdk/credential-provider-node@3.972.73':
+ resolution: {integrity: sha512-VTzdbf8Ukjdb9yUubZzRI678CWZvKovhE8Nv3qihwhC187sRMGls+r9N8Wuht5q1xjKx2nmpS48ar8ppupjkCA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-process@3.972.60':
- resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==}
+ '@aws-sdk/credential-provider-process@3.972.62':
+ resolution: {integrity: sha512-zXYU9UWNL66gtMgNLhmxlrvEokuI7r6G2q7FRGu41Bya4iS30JLelUipJX9SV4zhyCPWJhI9Li54R1d9H8Tq6A==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-sso@3.973.4':
- resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==}
+ '@aws-sdk/credential-provider-sso@3.973.6':
+ resolution: {integrity: sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-web-identity@3.972.66':
- resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==}
+ '@aws-sdk/credential-provider-web-identity@3.972.68':
+ resolution: {integrity: sha512-bq+yTt+uWJx60VVp/OIAX5xqUAu/K2Uc3eknWnWl+KtfcU2CQe0uNw6lySrn2t5GKHq7jsV0Z63HiBGVtzr/lg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/lib-storage@3.1088.0':
- resolution: {integrity: sha512-OElyotfOuTLAkWeYfWvgFQ/ABVs5xi9+UzlnKveFnbyfzV9um0guDWGanG/xGlw9ofAiplscHlNauChtJUBNWA==}
+ '@aws-sdk/lib-storage@3.1091.0':
+ resolution: {integrity: sha512-KOeM5C8gNdO08522Yr5HM7xjOicpRPsBA8W/qcKTgOWUdyw9M3hxTvmSpp7UO78my+lvjiHsvvzTBiYBN4GbXQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
- '@aws-sdk/client-s3': ^3.1088.0
+ '@aws-sdk/client-s3': ^3.1091.0
- '@aws-sdk/middleware-sdk-s3@3.972.65':
- resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==}
+ '@aws-sdk/middleware-sdk-s3@3.972.67':
+ resolution: {integrity: sha512-aB1ahF9z4J5r8YK1QodwNX/P8XSKBFtnjf7h72XCs0BP3rtThOiXeA3Lm+Z+8tiN9Iwl5otsGeHaXmQoQ5MVfA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/nested-clients@3.997.34':
- resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==}
+ '@aws-sdk/nested-clients@3.997.36':
+ resolution: {integrity: sha512-b71Suv7L+DnhM0MsQHU4WO42I32kxLZi96PbVhZbxMYIoKnEZz3v+LSrG8fupAoA4cBSshCk1Dl/PeRz49qUSg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/signature-v4-multi-region@3.996.41':
- resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==}
+ '@aws-sdk/signature-v4-multi-region@3.996.42':
+ resolution: {integrity: sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/token-providers@3.1092.0':
- resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==}
+ '@aws-sdk/token-providers@3.1096.0':
+ resolution: {integrity: sha512-hdUS2hDppy3vkWeFl5y86RLNU6OWH2mQB09yOSsRefwhhGTSFPkaZvfLDD/9vFcvMzlr8QFQFw3fw2FtrurVQA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.2':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/xml-builder@3.972.36':
- resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==}
+ '@aws-sdk/xml-builder@3.972.37':
+ resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
@@ -500,59 +497,59 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
- '@biomejs/biome@2.5.4':
- resolution: {integrity: sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==}
+ '@biomejs/biome@2.5.5':
+ resolution: {integrity: sha512-r1S8nFsAG1MY+vJFZALzIvwXAJv6ejDQ0mxP21Tgr9YK3ZFtjrvbBwDdNhx1rUqvccEIeNg20cYCNzl6Cr69pQ==}
engines: {node: '>=14.21.3'}
hasBin: true
- '@biomejs/cli-darwin-arm64@2.5.4':
- resolution: {integrity: sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==}
+ '@biomejs/cli-darwin-arm64@2.5.5':
+ resolution: {integrity: sha512-kUrAhXVWUrwmAUnV2iXSK7umxKFysTwvqK+Ty6ptUcLY/7T3SnCAjUowE4uvwaEej6nXZ7hu/dTtbokKdsPeag==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
- '@biomejs/cli-darwin-x64@2.5.4':
- resolution: {integrity: sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==}
+ '@biomejs/cli-darwin-x64@2.5.5':
+ resolution: {integrity: sha512-DamiYc5bUYZ2uxlfc+RLEPtz1Abb6PO5eTbOkufLpSGwd/7AMQAdxhFYiXmwwkJL8IsT8S7GvdgwDHqaMFAvKw==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
- '@biomejs/cli-linux-arm64-musl@2.5.4':
- resolution: {integrity: sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==}
+ '@biomejs/cli-linux-arm64-musl@2.5.5':
+ resolution: {integrity: sha512-U4WMl/sy/E/Q73vf15VspakLRRs2LDFcCeBxJnQfXzssb88zpV6PJPaQ3ezhQ7H6Ht2/8bvuZeHgJWzmoxllZg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@biomejs/cli-linux-arm64@2.5.4':
- resolution: {integrity: sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==}
+ '@biomejs/cli-linux-arm64@2.5.5':
+ resolution: {integrity: sha512-lRKF/pH/1RiYiBKExi3TCZVAtvzEm77aifrvcNiDFrR9WxeAnDUjDnseb6y2XV85mjitLs6SILGm2XG77cHtSQ==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@biomejs/cli-linux-x64-musl@2.5.4':
- resolution: {integrity: sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==}
+ '@biomejs/cli-linux-x64-musl@2.5.5':
+ resolution: {integrity: sha512-m7wC7tjX5Lrmo69dc4md8FeKpPU1NTCY1v7xUoQQ2vadWwNnBS0KZOG8471otFPHrTHihQJAjQPgMObpLvDe6A==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@biomejs/cli-linux-x64@2.5.4':
- resolution: {integrity: sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==}
+ '@biomejs/cli-linux-x64@2.5.5':
+ resolution: {integrity: sha512-H/O39nJEw/2Zm/fm7hrmxxoF8kK/aU1uCoPp70ruXVbomaAdLpJJnCmL11Q2JotT8QVHH06So04Oq53lCSwSwQ==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@biomejs/cli-win32-arm64@2.5.4':
- resolution: {integrity: sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==}
+ '@biomejs/cli-win32-arm64@2.5.5':
+ resolution: {integrity: sha512-7BryINPuYypLUAH3o/o5ZdgomJ4zn3EDR0ChZJst7n32S6ZhKbgHXuYydLu+YAnx59ehGFR0z/MG6qnzQi3Yyw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
- '@biomejs/cli-win32-x64@2.5.4':
- resolution: {integrity: sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==}
+ '@biomejs/cli-win32-x64@2.5.5':
+ resolution: {integrity: sha512-bIBFo+n6MIxdNcVFy5CrurbKiZQiUciK3bt8+O9I4wjFZNTfXLpi+giq47522eXqW5NBc9ulx7dR1SlZKi2J5g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
@@ -716,12 +713,15 @@ packages:
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@oslojs/encoding@0.4.1':
resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==}
@@ -731,6 +731,7 @@ packages:
'@oslojs/jwt@0.2.0':
resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@oxc-parser/binding-android-arm-eabi@0.137.0':
resolution: {integrity: sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==}
@@ -1418,15 +1419,6 @@ packages:
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
- '@remix-run/form-data-parser@0.17.4':
- resolution: {integrity: sha512-mqWVpD2OaJTFEW2i+M49/iWoUp2Cai6WD606/iynZ+NIlzVdkty3bbGb8g7vTjXeV/sQRmPbg3SJypgvQitvJg==}
-
- '@remix-run/headers@0.21.1':
- resolution: {integrity: sha512-DRhRveigepAQDUIi0MrOFhpcl3/KTv/fkpIhulv7Asv1pUwM8RpY2ENjdI8lfii6Z3MEsWtYhGt6If8bi6bYpQ==}
-
- '@remix-run/multipart-parser@0.16.3':
- resolution: {integrity: sha512-XvMYPIyDTuquR7s1YF4wo4CqMPDrBkpWY0zHSa7SpX0F+t9awg7FWd5mJywc6vTn4oWeS7nQYQQarZ9CuoC/RQ==}
-
'@remix-run/node-fetch-server@0.13.3':
resolution: {integrity: sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==}
@@ -1534,16 +1526,16 @@ packages:
os: [linux]
libc: [glibc]
- '@sentry/browser-utils@10.65.0':
- resolution: {integrity: sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==}
+ '@sentry/browser-utils@10.67.0':
+ resolution: {integrity: sha512-HUzaf0xAnPAB+OHBkD7N1Py+CTbD5InHulQ/pdhX4JctWtxuwD8odMD1LzdPnW8J6gVHlDVvcVBR8mXMZYSLSw==}
engines: {node: '>=18'}
- '@sentry/browser@10.65.0':
- resolution: {integrity: sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==}
+ '@sentry/browser@10.67.0':
+ resolution: {integrity: sha512-/ZhsAvte4rYhg0A0RtSFFgAgXhyMOfQIeOAfMfptN+X6IVSYOfkA9jtrP+Ej4+6vlaUFWRir1HweF56y63dEEA==}
engines: {node: '>=18'}
- '@sentry/bundler-plugins@10.67.0':
- resolution: {integrity: sha512-HKLhbMZJsabZlXTog8CTa1ReeDW/mf1cwN7O8K+DKhe/kGHB3whHRseqsyjxyJwPdzC/0lM+8rgfqgxpc7jR9A==}
+ '@sentry/bundler-plugins@10.68.0':
+ resolution: {integrity: sha512-XWv7asJuTTUSlacROvqcIFKuNxAf3PYL/mdjMBhBwp3rJ4vMkA73jqNPjqCTm726cHs/Y4yadbbFA/OZLPWzeg==}
engines: {node: '>= 18'}
peerDependencies:
rollup: '>=3.2.0'
@@ -1606,28 +1598,24 @@ packages:
engines: {node: '>= 10'}
hasBin: true
- '@sentry/conventions@0.15.1':
- resolution: {integrity: sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==}
- engines: {node: '>=14'}
-
'@sentry/conventions@0.16.0':
resolution: {integrity: sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ==}
engines: {node: '>=14'}
- '@sentry/core@10.65.0':
- resolution: {integrity: sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==}
- engines: {node: '>=18'}
-
'@sentry/core@10.67.0':
resolution: {integrity: sha512-b6U3pJ8AUvN9aouq0vl+VZI8KT8RslBsfGMFuNwRr313zOmdmFJBZqTiUw9VGgJ2jGKxLO9alm9rlxBfX4hf+w==}
engines: {node: '>=18'}
- '@sentry/feedback@10.65.0':
- resolution: {integrity: sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==}
+ '@sentry/core@10.68.0':
+ resolution: {integrity: sha512-5Amhx8ltVz7vb1bRGyf3c4J69/iHW8R/H+SJxTRILHlsSOBrnVVc/IQEYDC6PTRdRdZ3x2u7RVjxZi2Mhe525g==}
engines: {node: '>=18'}
- '@sentry/node-core@10.65.0':
- resolution: {integrity: sha512-U01X9mPT+jZnsLPmPWfBU67Ka+t/Sdd9RGAuvGoKdrI6N47a/9PDkM9oCW+kj0fmZwogZHTgSnzJU5oi3pImgA==}
+ '@sentry/feedback@10.67.0':
+ resolution: {integrity: sha512-I4ML2/SF3enwikb6ZSoRiqolQrx0zSzTSnUgwCmugICF/jpHW0th1pCray9R+t1Zzibw/Dpj4t/DNXaSDRa2MA==}
+ engines: {node: '>=18'}
+
+ '@sentry/node-core@10.67.0':
+ resolution: {integrity: sha512-dBHHRwZyan1pOnFJ+sNBvR8TkXbZAfZU/jpxmALS3JZ2/8AGR7cQKL+b7SleKuJ7iUDZyklN3Nqi0i5JkcA+HA==}
engines: {node: '>=18'}
peerDependencies:
'@opentelemetry/api': ^1.9.0
@@ -1647,66 +1635,66 @@ packages:
'@opentelemetry/sdk-trace-base':
optional: true
- '@sentry/node@10.65.0':
- resolution: {integrity: sha512-t35dcdyksysVch/m/XdLgGJqGKJhr9eMD30Ctn3TeQ8yMB0wNXySfjPR5Yg93fpjmfaHtzc6iYIXRAvgNVfrvA==}
+ '@sentry/node@10.67.0':
+ resolution: {integrity: sha512-SFKpZGqOCEFSmP93NdDP6ikZp4NS7A/JR8+2ofK3jF6Y9Vyox7pX0pxdOnPLpFcPtMybMahfWqSAWJvsFs4RmA==}
engines: {node: '>=18'}
- '@sentry/opentelemetry@10.65.0':
- resolution: {integrity: sha512-8C6FPvm3XBvUrkM52dX3Gz0p2H0Ij8t4sahUA+GTiCz0WM0fnyPeQPGC/b6I4jamV9UXyCZRnE1UEEGCoD+c7A==}
+ '@sentry/opentelemetry@10.67.0':
+ resolution: {integrity: sha512-oLTOrAK1rOqmYRktOJZwz37B1seXPx1W2FTMVtzTVNjMFA/LZwGzePeZzhUOgzZgfLHixMd/ceWtGqoxAndcjQ==}
engines: {node: '>=18'}
peerDependencies:
'@opentelemetry/api': ^1.9.0
'@opentelemetry/core': ^1.30.1 || ^2.1.0
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
- '@sentry/react-router@10.65.0':
- resolution: {integrity: sha512-U0T7VtXx2mHFsEpB/6mhwdL9kMIHGf5QLsOWY239TYp9VcniZHY3tOsh31LQmtZ7da9RVicC8O24Gtn5Zrhw8A==}
+ '@sentry/react-router@10.67.0':
+ resolution: {integrity: sha512-Fl0nkQxAJxMxnOUHHVIYELRQl4i9/KgDoq0JWGGZEfNbELLTSZTsACFoPceNrzZpmC25kJEG07hx02WCPguWtQ==}
engines: {node: '>=20'}
peerDependencies:
'@react-router/node': 7.x || ^8.x
react: '>=18'
react-router: 7.x || ^8.x
- '@sentry/react@10.65.0':
- resolution: {integrity: sha512-fvHxpuvid0wt9/1N3itcKDyKOjqmYHw3MBSt5Pki3Iz4CL2CmgQp9ZFv/CA7UhMnEvn2Gd+Qc2UKxujZWd8FLg==}
+ '@sentry/react@10.67.0':
+ resolution: {integrity: sha512-fS0DplcP9eMxBIRurPC/uxa4NrFK+l9ZsnvQo7wZvNutc7DpTAH0hgFt6laVNCe57s1pFo+OZuKsYBA6JDvH4Q==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
- '@sentry/replay-canvas@10.65.0':
- resolution: {integrity: sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==}
+ '@sentry/replay-canvas@10.67.0':
+ resolution: {integrity: sha512-neNA4T6MFtZzMdKYetiR+LZd9BNSd0q2szMn0wk+A15PqHE/IN7a34V6JZc9rCtmzB0wldh0eWGOBb49MSNKjA==}
engines: {node: '>=18'}
- '@sentry/replay@10.65.0':
- resolution: {integrity: sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==}
+ '@sentry/replay@10.67.0':
+ resolution: {integrity: sha512-nkEUgPCR82EcyJkCf3XCE9H0R5KisCqyCAaSGxe7NpAoQbvASHx4MUNgXVAn+D0M494gvPZh6lFH7JgzqTcSqQ==}
engines: {node: '>=18'}
- '@sentry/server-utils@10.65.0':
- resolution: {integrity: sha512-80toEFD6s+0Le7jrYB6pHWLF703WSg0WyavAWqrBGWG8JkREHgedAxzFYgoY5GlMI756qk6Ea7UzhJTHd2zAXA==}
+ '@sentry/server-utils@10.67.0':
+ resolution: {integrity: sha512-GQ9t+RSTx5s3b/aZrLFuL4nrwPLMah5NiZk5cjxJmgmOSgm3nMdO8gdqCedDch5B13F3YsxtaQYjSJnzLz8M5A==}
engines: {node: '>=18'}
'@sentry/vite-plugin@5.4.0':
resolution: {integrity: sha512-fFJgCxs5hDyAm9BbZJ+LbA+LK2tjX5OoD0v0ARU4StR6KQmGUduoPs69yJ9AfqZ0om3Rlp5JDliiwFcNkasORA==}
engines: {node: '>= 18'}
- '@smithy/core@3.29.7':
- resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==}
+ '@smithy/core@3.31.0':
+ resolution: {integrity: sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==}
engines: {node: '>=18.0.0'}
- '@smithy/credential-provider-imds@4.4.12':
- resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==}
+ '@smithy/credential-provider-imds@4.4.15':
+ resolution: {integrity: sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==}
engines: {node: '>=18.0.0'}
- '@smithy/fetch-http-handler@5.6.9':
- resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==}
+ '@smithy/fetch-http-handler@5.6.12':
+ resolution: {integrity: sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==}
engines: {node: '>=18.0.0'}
- '@smithy/node-http-handler@4.9.9':
- resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==}
+ '@smithy/node-http-handler@4.9.12':
+ resolution: {integrity: sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==}
engines: {node: '>=18.0.0'}
- '@smithy/signature-v4@5.6.8':
- resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==}
+ '@smithy/signature-v4@5.6.11':
+ resolution: {integrity: sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.16.1':
@@ -2854,6 +2842,7 @@ packages:
'@zumer/snapdom@2.16.0':
resolution: {integrity: sha512-ZfN3o6kcxb+jDhYe/JHFZf3g4UfDv3MGyTiK/sWvFU+vYlxa2KZVLZH7DxhKkobwGp66KMhRnXTqIKmaSgM50Q==}
+ deprecated: Old versions have known fidelity bugs (text re-wrap, width freezing, KaTeX clipping). Upgrade to ^2.22 — drop-in for any 2.x. https://github.com/zumerlab/snapdom/releases
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
@@ -2877,6 +2866,7 @@ packages:
arctic@3.7.0:
resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
@@ -2921,15 +2911,9 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
- better-sqlite3@12.11.1:
- resolution: {integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==}
- engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
-
- bindings@1.5.0:
- resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
-
- bl@4.1.0:
- resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
+ better-sqlite3@13.0.1:
+ resolution: {integrity: sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==}
+ engines: {node: '>=22'}
blueimp-canvas-to-blob@3.29.0:
resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==}
@@ -2944,9 +2928,9 @@ packages:
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
- brace-expansion@5.0.7:
- resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
- engines: {node: 18 || 20 || >=22}
+ brace-expansion@5.0.8:
+ resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
+ engines: {node: 20 || >=22}
browserslist@4.28.6:
resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==}
@@ -2962,9 +2946,6 @@ packages:
buffer@5.6.0:
resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==}
- buffer@5.7.1:
- resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
-
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -3000,9 +2981,6 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
- chownr@1.1.4:
- resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
-
cjs-module-lexer@2.2.0:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
@@ -3108,10 +3086,6 @@ packages:
supports-color:
optional: true
- decompress-response@6.0.0:
- resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
- engines: {node: '>=10'}
-
dedent@1.7.2:
resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==}
peerDependencies:
@@ -3120,10 +3094,6 @@ packages:
babel-plugin-macros:
optional: true
- deep-extend@0.6.0:
- resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
- engines: {node: '>=4.0.0'}
-
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -3169,9 +3139,6 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
- end-of-stream@1.4.5:
- resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
-
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -3239,10 +3206,6 @@ packages:
resolution: {integrity: sha512-INjr2xyxHo7bhAqf5ong++GZPPnpcuBcaXUKt03yf7Fie9yWD7FapL4teOU0+awQazGs5ucBh7xWs/AD+6nhog==}
engines: {node: '>=20'}
- expand-template@2.0.3:
- resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
- engines: {node: '>=6'}
-
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -3276,9 +3239,6 @@ packages:
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
- file-uri-to-path@1.0.0:
- resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
-
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
@@ -3310,9 +3270,6 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
- fs-constants@1.0.0:
- resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
-
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -3353,9 +3310,6 @@ packages:
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
- github-from-package@0.0.0:
- resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
-
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -3445,9 +3399,6 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
- ini@1.3.8:
- resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
-
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -3645,8 +3596,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
- lucide-react@1.24.0:
- resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
+ lucide-react@1.25.0:
+ resolution: {integrity: sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3661,8 +3612,8 @@ packages:
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
hasBin: true
- markdown-to-jsx@9.8.2:
- resolution: {integrity: sha512-rWUuxKB5NsuJmSfUOuXkQ0O5qk0J/Lr3Lk6dzxKoKQI/jeHYlsVfz3zJdMLAhI46hHoXDYERWhtBOiqtWDZ4LA==}
+ markdown-to-jsx@9.9.0:
+ resolution: {integrity: sha512-3YRQuriODgdMr9EeY//Ya/104p978bY0nOwmc85IGxHixqOlhNDgq7IJTsLOdmFxjMFKJ6bVzUav30wWVJZOJQ==}
engines: {node: '>= 18'}
peerDependencies:
react: '>= 16.0.0'
@@ -3706,15 +3657,11 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
- mimic-response@3.1.0:
- resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
- engines: {node: '>=10'}
-
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
- minimatch@10.2.5:
- resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
@@ -3728,9 +3675,6 @@ packages:
resolution: {integrity: sha512-FEZDdUFb88hgdnsfAPa4VxcPVOd+8GyZ2jsI965im5bjBlBYhrvXuTqLka/UHa6YdUNN/kZBsVgofhZ3322AJw==}
engines: {node: '>=6'}
- mkdirp-classic@0.5.3:
- resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
-
module-details-from-path@1.0.4:
resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==}
@@ -3755,24 +3699,16 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
- nanoid@3.3.15:
- resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
- nanoid@5.1.16:
- resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
- engines: {node: ^18 || >=20}
+ nanoid@6.0.0:
+ resolution: {integrity: sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==}
+ engines: {node: ^22 || ^24 || >=26}
hasBin: true
- napi-build-utils@2.0.0:
- resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
-
nearley@2.20.1:
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
hasBin: true
@@ -3789,9 +3725,9 @@ packages:
resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==}
engines: {node: '>=18'}
- node-abi@3.93.0:
- resolution: {integrity: sha512-Cu6yUpX5Iavugm8BeX7c0wgU9CvOqfd1yM6A1d2q2ZMjym7GjpASv2GdRcTq3Fx+Sb5OgBkEEpw4VnAbY6Y5RA==}
- engines: {node: '>=10'}
+ node-addon-api@8.9.0:
+ resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==}
+ engines: {node: ^18 || ^20 || >= 21}
node-cron@4.6.0:
resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==}
@@ -3861,8 +3797,8 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
- p-limit@7.3.0:
- resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
+ p-limit@7.3.1:
+ resolution: {integrity: sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==}
engines: {node: '>=20'}
p-locate@5.0.0:
@@ -3938,12 +3874,6 @@ packages:
resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==}
engines: {node: ^10 || ^12 || >=14}
- prebuild-install@7.1.3:
- resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
- engines: {node: '>=10'}
- deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
- hasBin: true
-
prettier@3.9.5:
resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==}
engines: {node: '>=14'}
@@ -4024,9 +3954,6 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
- pump@3.0.4:
- resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
-
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
@@ -4062,10 +3989,6 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
- rc@1.2.8:
- resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
- hasBin: true
-
react-aria-components@1.19.0:
resolution: {integrity: sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==}
peerDependencies:
@@ -4324,12 +4247,6 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
- simple-concat@1.0.1:
- resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
-
- simple-get@4.0.1:
- resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
-
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
@@ -4392,10 +4309,6 @@ packages:
resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
engines: {node: '>=0.10.0'}
- strip-json-comments@2.0.1:
- resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
- engines: {node: '>=0.10.0'}
-
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
@@ -4413,13 +4326,6 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- tar-fs@2.1.5:
- resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==}
-
- tar-stream@2.2.0:
- resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
- engines: {node: '>=6'}
-
tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
@@ -4471,9 +4377,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
- tunnel-agent@0.6.0:
- resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
-
type-fest@2.19.0:
resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
engines: {node: '>=12.20'}
@@ -4767,14 +4670,14 @@ packages:
snapshots:
- '@apm-js-collab/code-transformer-bundler-plugins@0.5.0':
+ '@apm-js-collab/code-transformer-bundler-plugins@0.7.3':
dependencies:
- '@apm-js-collab/code-transformer': 0.15.0
+ '@apm-js-collab/code-transformer': 0.18.1
es-module-lexer: 2.3.1
magic-string: 0.30.21
module-details-from-path: 1.0.4
- '@apm-js-collab/code-transformer@0.15.0':
+ '@apm-js-collab/code-transformer@0.18.1':
dependencies:
'@types/estree': 1.0.9
astring: 1.9.0
@@ -4783,174 +4686,174 @@ snapshots:
semifies: 1.0.0
source-map: 0.6.1
- '@apm-js-collab/tracing-hooks@0.10.1':
+ '@apm-js-collab/tracing-hooks@0.13.0':
dependencies:
- '@apm-js-collab/code-transformer': 0.15.0
+ '@apm-js-collab/code-transformer': 0.18.1
debug: 4.4.3
module-details-from-path: 1.0.4
transitivePeerDependencies:
- supports-color
- '@aws-sdk/checksums@3.1000.19':
+ '@aws-sdk/checksums@3.1000.21':
dependencies:
- '@aws-sdk/core': 3.976.0
+ '@aws-sdk/core': 3.977.1
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/client-s3@3.1088.0':
+ '@aws-sdk/client-s3@3.1091.0':
dependencies:
- '@aws-sdk/checksums': 3.1000.19
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/credential-provider-node': 3.972.71
- '@aws-sdk/middleware-sdk-s3': 3.972.65
- '@aws-sdk/signature-v4-multi-region': 3.996.41
+ '@aws-sdk/checksums': 3.1000.21
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/credential-provider-node': 3.972.73
+ '@aws-sdk/middleware-sdk-s3': 3.972.67
+ '@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
- '@smithy/fetch-http-handler': 5.6.9
- '@smithy/node-http-handler': 4.9.9
+ '@smithy/core': 3.31.0
+ '@smithy/fetch-http-handler': 5.6.12
+ '@smithy/node-http-handler': 4.9.12
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/core@3.976.0':
+ '@aws-sdk/core@3.977.1':
dependencies:
'@aws-sdk/types': 3.974.2
- '@aws-sdk/xml-builder': 3.972.36
+ '@aws-sdk/xml-builder': 3.972.37
'@aws/lambda-invoke-store': 0.3.0
- '@smithy/core': 3.29.7
- '@smithy/signature-v4': 5.6.8
+ '@smithy/core': 3.31.0
+ '@smithy/signature-v4': 5.6.11
'@smithy/types': 4.16.1
bowser: 2.14.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-env@3.972.60':
+ '@aws-sdk/credential-provider-env@3.972.62':
dependencies:
- '@aws-sdk/core': 3.976.0
+ '@aws-sdk/core': 3.977.1
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-http@3.972.62':
+ '@aws-sdk/credential-provider-http@3.972.64':
dependencies:
- '@aws-sdk/core': 3.976.0
+ '@aws-sdk/core': 3.977.1
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
- '@smithy/fetch-http-handler': 5.6.9
- '@smithy/node-http-handler': 4.9.9
+ '@smithy/core': 3.31.0
+ '@smithy/fetch-http-handler': 5.6.12
+ '@smithy/node-http-handler': 4.9.12
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-ini@3.973.5':
+ '@aws-sdk/credential-provider-ini@3.973.7':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/credential-provider-env': 3.972.60
- '@aws-sdk/credential-provider-http': 3.972.62
- '@aws-sdk/credential-provider-login': 3.972.67
- '@aws-sdk/credential-provider-process': 3.972.60
- '@aws-sdk/credential-provider-sso': 3.973.4
- '@aws-sdk/credential-provider-web-identity': 3.972.66
- '@aws-sdk/nested-clients': 3.997.34
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/credential-provider-env': 3.972.62
+ '@aws-sdk/credential-provider-http': 3.972.64
+ '@aws-sdk/credential-provider-login': 3.972.69
+ '@aws-sdk/credential-provider-process': 3.972.62
+ '@aws-sdk/credential-provider-sso': 3.973.6
+ '@aws-sdk/credential-provider-web-identity': 3.972.68
+ '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
- '@smithy/credential-provider-imds': 4.4.12
+ '@smithy/core': 3.31.0
+ '@smithy/credential-provider-imds': 4.4.15
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-login@3.972.67':
+ '@aws-sdk/credential-provider-login@3.972.69':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/nested-clients': 3.997.34
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-node@3.972.71':
+ '@aws-sdk/credential-provider-node@3.972.73':
dependencies:
- '@aws-sdk/credential-provider-env': 3.972.60
- '@aws-sdk/credential-provider-http': 3.972.62
- '@aws-sdk/credential-provider-ini': 3.973.5
- '@aws-sdk/credential-provider-process': 3.972.60
- '@aws-sdk/credential-provider-sso': 3.973.4
- '@aws-sdk/credential-provider-web-identity': 3.972.66
+ '@aws-sdk/credential-provider-env': 3.972.62
+ '@aws-sdk/credential-provider-http': 3.972.64
+ '@aws-sdk/credential-provider-ini': 3.973.7
+ '@aws-sdk/credential-provider-process': 3.972.62
+ '@aws-sdk/credential-provider-sso': 3.973.6
+ '@aws-sdk/credential-provider-web-identity': 3.972.68
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
- '@smithy/credential-provider-imds': 4.4.12
+ '@smithy/core': 3.31.0
+ '@smithy/credential-provider-imds': 4.4.15
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-process@3.972.60':
+ '@aws-sdk/credential-provider-process@3.972.62':
dependencies:
- '@aws-sdk/core': 3.976.0
+ '@aws-sdk/core': 3.977.1
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-sso@3.973.4':
+ '@aws-sdk/credential-provider-sso@3.973.6':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/nested-clients': 3.997.34
- '@aws-sdk/token-providers': 3.1092.0
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.36
+ '@aws-sdk/token-providers': 3.1096.0
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-web-identity@3.972.66':
+ '@aws-sdk/credential-provider-web-identity@3.972.68':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/nested-clients': 3.997.34
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/lib-storage@3.1088.0(@aws-sdk/client-s3@3.1088.0)':
+ '@aws-sdk/lib-storage@3.1091.0(@aws-sdk/client-s3@3.1091.0)':
dependencies:
- '@aws-sdk/client-s3': 3.1088.0
- '@smithy/core': 3.29.7
+ '@aws-sdk/client-s3': 3.1091.0
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
buffer: 5.6.0
events: 3.3.0
stream-browserify: 3.0.0
tslib: 2.8.1
- '@aws-sdk/middleware-sdk-s3@3.972.65':
+ '@aws-sdk/middleware-sdk-s3@3.972.67':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/signature-v4-multi-region': 3.996.41
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/nested-clients@3.997.34':
+ '@aws-sdk/nested-clients@3.997.36':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/signature-v4-multi-region': 3.996.41
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/signature-v4-multi-region': 3.996.42
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
- '@smithy/fetch-http-handler': 5.6.9
- '@smithy/node-http-handler': 4.9.9
+ '@smithy/core': 3.31.0
+ '@smithy/fetch-http-handler': 5.6.12
+ '@smithy/node-http-handler': 4.9.12
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/signature-v4-multi-region@3.996.41':
+ '@aws-sdk/signature-v4-multi-region@3.996.42':
dependencies:
'@aws-sdk/types': 3.974.2
- '@smithy/signature-v4': 5.6.8
+ '@smithy/signature-v4': 5.6.11
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/token-providers@3.1092.0':
+ '@aws-sdk/token-providers@3.1096.0':
dependencies:
- '@aws-sdk/core': 3.976.0
- '@aws-sdk/nested-clients': 3.997.34
+ '@aws-sdk/core': 3.977.1
+ '@aws-sdk/nested-clients': 3.997.36
'@aws-sdk/types': 3.974.2
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
@@ -4959,7 +4862,7 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@aws-sdk/xml-builder@3.972.36':
+ '@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
@@ -5165,39 +5068,39 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
- '@biomejs/biome@2.5.4':
+ '@biomejs/biome@2.5.5':
optionalDependencies:
- '@biomejs/cli-darwin-arm64': 2.5.4
- '@biomejs/cli-darwin-x64': 2.5.4
- '@biomejs/cli-linux-arm64': 2.5.4
- '@biomejs/cli-linux-arm64-musl': 2.5.4
- '@biomejs/cli-linux-x64': 2.5.4
- '@biomejs/cli-linux-x64-musl': 2.5.4
- '@biomejs/cli-win32-arm64': 2.5.4
- '@biomejs/cli-win32-x64': 2.5.4
+ '@biomejs/cli-darwin-arm64': 2.5.5
+ '@biomejs/cli-darwin-x64': 2.5.5
+ '@biomejs/cli-linux-arm64': 2.5.5
+ '@biomejs/cli-linux-arm64-musl': 2.5.5
+ '@biomejs/cli-linux-x64': 2.5.5
+ '@biomejs/cli-linux-x64-musl': 2.5.5
+ '@biomejs/cli-win32-arm64': 2.5.5
+ '@biomejs/cli-win32-x64': 2.5.5
- '@biomejs/cli-darwin-arm64@2.5.4':
+ '@biomejs/cli-darwin-arm64@2.5.5':
optional: true
- '@biomejs/cli-darwin-x64@2.5.4':
+ '@biomejs/cli-darwin-x64@2.5.5':
optional: true
- '@biomejs/cli-linux-arm64-musl@2.5.4':
+ '@biomejs/cli-linux-arm64-musl@2.5.5':
optional: true
- '@biomejs/cli-linux-arm64@2.5.4':
+ '@biomejs/cli-linux-arm64@2.5.5':
optional: true
- '@biomejs/cli-linux-x64-musl@2.5.4':
+ '@biomejs/cli-linux-x64-musl@2.5.5':
optional: true
- '@biomejs/cli-linux-x64@2.5.4':
+ '@biomejs/cli-linux-x64@2.5.5':
optional: true
- '@biomejs/cli-win32-arm64@2.5.4':
+ '@biomejs/cli-win32-arm64@2.5.5':
optional: true
- '@biomejs/cli-win32-x64@2.5.4':
+ '@biomejs/cli-win32-x64@2.5.5':
optional: true
'@blazediff/core@1.9.1': {}
@@ -5984,16 +5887,6 @@ snapshots:
'@remirror/core-constants@3.0.0': {}
- '@remix-run/form-data-parser@0.17.4':
- dependencies:
- '@remix-run/multipart-parser': 0.16.3
-
- '@remix-run/headers@0.21.1': {}
-
- '@remix-run/multipart-parser@0.16.3':
- dependencies:
- '@remix-run/headers': 0.21.1
-
'@remix-run/node-fetch-server@0.13.3': {}
'@rolldown/binding-android-arm64@1.1.5':
@@ -6050,25 +5943,25 @@ snapshots:
'@rollup/rollup-linux-x64-gnu@4.60.0':
optional: true
- '@sentry/browser-utils@10.65.0':
+ '@sentry/browser-utils@10.67.0':
dependencies:
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
- '@sentry/browser@10.65.0':
+ '@sentry/browser@10.67.0':
dependencies:
- '@sentry/browser-utils': 10.65.0
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
- '@sentry/feedback': 10.65.0
- '@sentry/replay': 10.65.0
- '@sentry/replay-canvas': 10.65.0
+ '@sentry/browser-utils': 10.67.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
+ '@sentry/feedback': 10.67.0
+ '@sentry/replay': 10.67.0
+ '@sentry/replay-canvas': 10.67.0
- '@sentry/bundler-plugins@10.67.0':
+ '@sentry/bundler-plugins@10.68.0':
dependencies:
'@babel/core': 7.29.7
'@sentry/cli': 2.58.6
- '@sentry/core': 10.67.0
+ '@sentry/core': 10.68.0
dotenv: 17.4.2
find-up: 5.0.0
glob: 13.0.6
@@ -6121,27 +6014,25 @@ snapshots:
- encoding
- supports-color
- '@sentry/conventions@0.15.1': {}
-
'@sentry/conventions@0.16.0': {}
- '@sentry/core@10.65.0':
- dependencies:
- '@sentry/conventions': 0.15.1
-
'@sentry/core@10.67.0':
dependencies:
'@sentry/conventions': 0.16.0
- '@sentry/feedback@10.65.0':
+ '@sentry/core@10.68.0':
dependencies:
- '@sentry/core': 10.65.0
+ '@sentry/conventions': 0.16.0
- '@sentry/node-core@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
+ '@sentry/feedback@10.67.0':
dependencies:
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
- '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/core': 10.67.0
+
+ '@sentry/node-core@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
+ dependencies:
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
+ '@sentry/opentelemetry': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
import-in-the-middle: 3.3.2
optionalDependencies:
'@opentelemetry/api': 1.9.1
@@ -6149,41 +6040,41 @@ snapshots:
'@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
- '@sentry/node@10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))':
+ '@sentry/node@10.67.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
- '@sentry/node-core': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
- '@sentry/opentelemetry': 10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
- '@sentry/server-utils': 10.65.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
+ '@sentry/node-core': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/opentelemetry': 10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/server-utils': 10.67.0
import-in-the-middle: 3.3.2
transitivePeerDependencies:
- '@opentelemetry/core'
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
- '@sentry/opentelemetry@10.65.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
+ '@sentry/opentelemetry@10.67.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1)
'@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1)
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
- '@sentry/react-router@10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
+ '@sentry/react-router@10.67.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))(@react-router/node@8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2))(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)':
dependencies:
'@opentelemetry/api': 1.9.1
'@opentelemetry/instrumentation': 0.220.0(@opentelemetry/api@1.9.1)
'@react-router/node': 8.2.0(react-router@8.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(typescript@7.0.2)
- '@sentry/browser': 10.65.0
+ '@sentry/browser': 10.67.0
'@sentry/cli': 2.58.6
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
- '@sentry/node': 10.65.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))
- '@sentry/react': 10.65.0(react@19.2.7)
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
+ '@sentry/node': 10.67.0(@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1))
+ '@sentry/react': 10.67.0(react@19.2.7)
'@sentry/vite-plugin': 5.4.0
glob: 13.0.6
react: 19.2.7
@@ -6196,69 +6087,67 @@ snapshots:
- supports-color
- webpack
- '@sentry/react@10.65.0(react@19.2.7)':
+ '@sentry/react@10.67.0(react@19.2.7)':
dependencies:
- '@sentry/browser': 10.65.0
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
+ '@sentry/browser': 10.67.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
react: 19.2.7
- '@sentry/replay-canvas@10.65.0':
+ '@sentry/replay-canvas@10.67.0':
dependencies:
- '@sentry/core': 10.65.0
- '@sentry/replay': 10.65.0
+ '@sentry/core': 10.67.0
+ '@sentry/replay': 10.67.0
- '@sentry/replay@10.65.0':
+ '@sentry/replay@10.67.0':
dependencies:
- '@sentry/browser-utils': 10.65.0
- '@sentry/core': 10.65.0
+ '@sentry/browser-utils': 10.67.0
+ '@sentry/core': 10.67.0
- '@sentry/server-utils@10.65.0':
+ '@sentry/server-utils@10.67.0':
dependencies:
- '@apm-js-collab/code-transformer': 0.15.0
- '@apm-js-collab/code-transformer-bundler-plugins': 0.5.0
- '@apm-js-collab/tracing-hooks': 0.10.1
- '@sentry/conventions': 0.15.1
- '@sentry/core': 10.65.0
- magic-string: 0.30.21
+ '@apm-js-collab/code-transformer-bundler-plugins': 0.7.3
+ '@apm-js-collab/tracing-hooks': 0.13.0
+ '@sentry/conventions': 0.16.0
+ '@sentry/core': 10.67.0
transitivePeerDependencies:
- supports-color
'@sentry/vite-plugin@5.4.0':
dependencies:
- '@sentry/bundler-plugins': 10.67.0
+ '@sentry/bundler-plugins': 10.68.0
transitivePeerDependencies:
- encoding
- rollup
- supports-color
- webpack
- '@smithy/core@3.29.7':
+ '@smithy/core@3.31.0':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@smithy/credential-provider-imds@4.4.12':
+ '@smithy/credential-provider-imds@4.4.15':
dependencies:
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@smithy/fetch-http-handler@5.6.9':
+ '@smithy/fetch-http-handler@5.6.12':
dependencies:
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@smithy/node-http-handler@4.9.9':
+ '@smithy/node-http-handler@4.9.12':
dependencies:
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
- '@smithy/signature-v4@5.6.8':
+ '@smithy/signature-v4@5.6.11':
dependencies:
- '@smithy/core': 3.29.7
+ '@smithy/core': 3.31.0
'@smithy/types': 4.16.1
tslib: 2.8.1
@@ -7630,20 +7519,9 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
- better-sqlite3@12.11.1:
+ better-sqlite3@13.0.1:
dependencies:
- bindings: 1.5.0
- prebuild-install: 7.1.3
-
- bindings@1.5.0:
- dependencies:
- file-uri-to-path: 1.0.0
-
- bl@4.1.0:
- dependencies:
- buffer: 5.7.1
- inherits: 2.0.4
- readable-stream: 3.6.2
+ node-addon-api: 8.9.0
blueimp-canvas-to-blob@3.29.0: {}
@@ -7665,7 +7543,7 @@ snapshots:
bowser@2.14.1: {}
- brace-expansion@5.0.7:
+ brace-expansion@5.0.8:
dependencies:
balanced-match: 4.0.4
@@ -7686,11 +7564,6 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
- buffer@5.7.1:
- dependencies:
- base64-js: 1.5.1
- ieee754: 1.2.1
-
bytes@3.1.2: {}
cac@7.0.0: {}
@@ -7722,8 +7595,6 @@ snapshots:
dependencies:
readdirp: 5.0.0
- chownr@1.1.4: {}
-
cjs-module-lexer@2.2.0: {}
classnames@2.5.1: {}
@@ -7810,14 +7681,8 @@ snapshots:
dependencies:
ms: 2.1.3
- decompress-response@6.0.0:
- dependencies:
- mimic-response: 3.1.0
-
dedent@1.7.2: {}
- deep-extend@0.6.0: {}
-
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -7850,10 +7715,6 @@ snapshots:
encodeurl@2.0.0: {}
- end-of-stream@1.4.5:
- dependencies:
- once: 1.4.0
-
entities@4.5.0: {}
es-define-property@1.0.1: {}
@@ -7896,8 +7757,6 @@ snapshots:
exit-hook@5.1.0: {}
- expand-template@2.0.3: {}
-
expect-type@1.4.0: {}
express@5.2.1:
@@ -7955,8 +7814,6 @@ snapshots:
fflate@0.8.3: {}
- file-uri-to-path@1.0.0: {}
-
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
@@ -7989,8 +7846,6 @@ snapshots:
fresh@2.0.0: {}
- fs-constants@1.0.0: {}
-
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -8033,11 +7888,9 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
- github-from-package@0.0.0: {}
-
glob@13.0.6:
dependencies:
- minimatch: 10.2.5
+ minimatch: 10.2.6
minipass: 7.1.3
path-scurry: 2.0.2
@@ -8114,7 +7967,7 @@ snapshots:
ics@3.12.0:
dependencies:
- nanoid: 3.3.15
+ nanoid: 3.3.16
runes2: 1.1.4
yup: 1.7.1
@@ -8130,8 +7983,6 @@ snapshots:
inherits@2.0.4: {}
- ini@1.3.8: {}
-
ipaddr.js@1.9.1: {}
is-blob@2.1.0: {}
@@ -8290,7 +8141,7 @@ snapshots:
dependencies:
yallist: 3.1.1
- lucide-react@1.24.0(react@19.2.7):
+ lucide-react@1.25.0(react@19.2.7):
dependencies:
react: 19.2.7
@@ -8309,7 +8160,7 @@ snapshots:
punycode.js: 2.3.1
uc.micro: 2.1.0
- markdown-to-jsx@9.8.2(react@19.2.7):
+ markdown-to-jsx@9.9.0(react@19.2.7):
optionalDependencies:
react: 19.2.7
@@ -8329,13 +8180,11 @@ snapshots:
dependencies:
mime-db: 1.54.0
- mimic-response@3.1.0: {}
-
minimalistic-assert@1.0.1: {}
- minimatch@10.2.5:
+ minimatch@10.2.6:
dependencies:
- brace-expansion: 5.0.7
+ brace-expansion: 5.0.8
minimist@1.2.8: {}
@@ -8343,8 +8192,6 @@ snapshots:
mk-dirs@3.0.0: {}
- mkdirp-classic@0.5.3: {}
-
module-details-from-path@1.0.4: {}
moo@0.5.3: {}
@@ -8367,13 +8214,9 @@ snapshots:
ms@2.1.3: {}
- nanoid@3.3.15: {}
-
nanoid@3.3.16: {}
- nanoid@5.1.16: {}
-
- napi-build-utils@2.0.0: {}
+ nanoid@6.0.0: {}
nearley@2.20.1:
dependencies:
@@ -8390,9 +8233,7 @@ snapshots:
optionalDependencies:
'@rollup/rollup-linux-x64-gnu': 4.60.0
- node-abi@3.93.0:
- dependencies:
- semver: 7.8.5
+ node-addon-api@8.9.0: {}
node-cron@4.6.0: {}
@@ -8489,7 +8330,7 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
- p-limit@7.3.0:
+ p-limit@7.3.1:
dependencies:
yocto-queue: 1.2.2
@@ -8550,21 +8391,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
- prebuild-install@7.1.3:
- dependencies:
- detect-libc: 2.1.2
- expand-template: 2.0.3
- github-from-package: 0.0.0
- minimist: 1.2.8
- mkdirp-classic: 0.5.3
- napi-build-utils: 2.0.0
- node-abi: 3.93.0
- pump: 3.0.4
- rc: 1.2.8
- simple-get: 4.0.1
- tar-fs: 2.1.5
- tunnel-agent: 0.6.0
-
prettier@3.9.5: {}
progress@2.0.3: {}
@@ -8687,11 +8513,6 @@ snapshots:
proxy-from-env@1.1.0: {}
- pump@3.0.4:
- dependencies:
- end-of-stream: 1.4.5
- once: 1.4.0
-
punycode.js@2.3.1: {}
qrcode.react@4.2.0(react@19.2.7):
@@ -8725,13 +8546,6 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
- rc@1.2.8:
- dependencies:
- deep-extend: 0.6.0
- ini: 1.3.8
- minimist: 1.2.8
- strip-json-comments: 2.0.1
-
react-aria-components@1.19.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
'@internationalized/date': 3.12.2
@@ -9023,14 +8837,6 @@ snapshots:
siginfo@2.0.0: {}
- simple-concat@1.0.1: {}
-
- simple-get@4.0.1:
- dependencies:
- decompress-response: 6.0.0
- once: 1.4.0
- simple-concat: 1.0.1
-
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -9086,8 +8892,6 @@ snapshots:
strip-bom-string@1.0.0: {}
- strip-json-comments@2.0.1: {}
-
strip-json-comments@5.0.3: {}
supports-color@7.2.0:
@@ -9102,21 +8906,6 @@ snapshots:
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
- tar-fs@2.1.5:
- dependencies:
- chownr: 1.1.4
- mkdirp-classic: 0.5.3
- pump: 3.0.4
- tar-stream: 2.2.0
-
- tar-stream@2.2.0:
- dependencies:
- bl: 4.1.0
- end-of-stream: 1.4.5
- fs-constants: 1.0.0
- inherits: 2.0.4
- readable-stream: 3.6.2
-
tiny-case@1.0.3: {}
tinybench@2.9.0: {}
@@ -9177,10 +8966,6 @@ snapshots:
tslib@2.8.1: {}
- tunnel-agent@0.6.0:
- dependencies:
- safe-buffer: 5.2.1
-
type-fest@2.19.0: {}
type-is@2.1.0:
diff --git a/vite.config.ts b/vite.config.ts
index d43491dae..bd96ccf9c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -105,7 +105,6 @@ export default defineConfig((config) => {
"@dnd-kit/utilities",
"@epic-web/cachified",
"@internationalized/date",
- "@remix-run/form-data-parser",
"@tldraw/tldraw",
"@zumer/snapdom",
"better-sqlite3",