Convert edit teams form to SendouForm (#3138)

This commit is contained in:
Kalle
2026-06-04 20:38:24 +03:00
committed by GitHub
parent 83d21879bf
commit 8afcd8e137
57 changed files with 1040 additions and 527 deletions

View File

@@ -6,6 +6,7 @@ import {
datetimeRequired,
dayMonthYearRequired,
dualSelectOptional,
image,
numberFieldOptional,
radioGroup,
select,
@@ -149,6 +150,15 @@ export const formFieldsShowcaseSchema = z.object({
label: "labels.banUserPlayer",
}),
// Image fields
logo: image({
label: "labels.logo",
}),
banner: image({
label: "labels.banner",
dimensions: "thick-banner",
}),
// Custom field
customValue: customField(
{ initialValue: "custom initial value" },

View File

@@ -2147,6 +2147,16 @@ function FormFieldsSection({ id }: { id: string }) {
<FormField name="user" />
</ComponentRow>
<Divider smallText>Image Fields</Divider>
<ComponentRow label="image (logo, default)">
<FormField name="logo" />
</ComponentRow>
<ComponentRow label="image (thick-banner)">
<FormField name="banner" />
</ComponentRow>
<Divider smallText>Custom Field</Divider>
<ComponentRow label="customField">

View File

@@ -397,6 +397,49 @@ describe("countUnvalidatedBySubmitterUserId", () => {
});
});
describe("countAllUnvalidatedBySubmitterUserId", () => {
beforeEach(async () => {
imageCounter = 0;
await dbInsertUsers(3);
});
afterEach(() => {
dbReset();
});
test("counts unvalidated images not associated with any entity", async () => {
await createImage({ submitterUserId: 1 });
await createImage({ submitterUserId: 1 });
const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1);
expect(count).toBe(2);
});
test("does not count validated images", async () => {
await createImage({ submitterUserId: 1, validatedAt: Date.now() });
const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1);
expect(count).toBe(0);
});
test("does not count images from other submitters", async () => {
await createImage({ submitterUserId: 1 });
await createImage({ submitterUserId: 2 });
const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1);
expect(count).toBe(1);
});
test("returns 0 when user has no unvalidated images", async () => {
const count = await ImageRepository.countAllUnvalidatedBySubmitterUserId(1);
expect(count).toBe(0);
});
});
describe("validateImage", () => {
beforeEach(async () => {
imageCounter = 0;

View File

@@ -1,4 +1,6 @@
import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
import { databaseTimestampNow } from "~/utils/dates";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { IMAGES_TO_VALIDATE_AT_ONCE } from "./upload-constants";
@@ -121,6 +123,21 @@ export async function countUnvalidatedBySubmitterUserId(userId: number) {
return result.count;
}
/**
* Counts every unvalidated image submitted by a user, regardless of whether it is yet associated
* with an entity. Unlike {@link countUnvalidatedBySubmitterUserId} (team-joined), this also counts
* not-yet-connected orphans, so it can gate the SendouForm `image()` upload path.
*/
export async function countAllUnvalidatedBySubmitterUserId(userId: number) {
const result = await db
.selectFrom("UnvalidatedUserSubmittedImage")
.select(({ fn }) => fn.countAll<number>().as("count"))
.where("validatedAt", "is", null)
.where("submitterUserId", "=", userId)
.executeTakeFirstOrThrow();
return result.count;
}
/** Marks an image as validated by setting the current timestamp */
export function validateImage(id: number) {
return db
@@ -130,6 +147,20 @@ export function validateImage(id: number) {
.execute();
}
/**
* Inserts an unvalidated image row without associating it with any owner. Returns the inserted row.
*/
export function insert(
args: TablesInsertable["UnvalidatedUserSubmittedImage"],
trx: Transaction<DB> | typeof db = db,
) {
return trx
.insertInto("UnvalidatedUserSubmittedImage")
.values(args)
.returningAll()
.executeTakeFirstOrThrow();
}
/** Creates a new image and associates it with a team or organization */
export function addNewImage({
submitterUserId,
@@ -147,11 +178,7 @@ export function addNewImage({
type: ImageUploadType;
}) {
return db.transaction().execute(async (trx) => {
const img = await trx
.insertInto("UnvalidatedUserSubmittedImage")
.values({ submitterUserId, url, validatedAt })
.returningAll()
.executeTakeFirstOrThrow();
const img = await insert({ submitterUserId, url, validatedAt }, trx);
if (type === "team-pfp" && teamId) {
await trx

View File

@@ -0,0 +1,77 @@
import { basename } from "node:path";
import { Readable } from "node:stream";
import type { AuthenticatedUser } from "~/features/auth/core/user.server";
import type { ImageFieldValue } from "~/form/image-field";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { shortNanoid } from "~/utils/id";
import invariant from "~/utils/invariant";
import { errorToastIfFalsy } from "~/utils/remix.server";
import * as ImageRepository from "./ImageRepository.server";
import { uploadStreamToS3 } from "./s3.server";
import { MAX_UNVALIDATED_IMG_COUNT } from "./upload-constants";
/**
* Resolves a SendouForm `image` field value to the image id to store on the consuming FK column.
*
* - `null` → `null` (image removed / none)
* - `EXISTING` → the unchanged `imgId` (no bytes are re-uploaded)
* - `NEW` → decodes the base64 webp, uploads it to S3 and inserts an unvalidated image row,
* auto-validating it for supporters, then returns the new id.
*
* The consuming action is responsible for writing the returned value to its own FK column.
*/
export async function imageFieldValueToImgId({
value,
user,
}: {
value: ImageFieldValue;
user: AuthenticatedUser;
}): Promise<number | null> {
if (!value) return null;
if (value.type === "EXISTING") return value.imgId;
errorToastIfFalsy(
(await ImageRepository.countAllUnvalidatedBySubmitterUserId(user.id)) <
MAX_UNVALIDATED_IMG_COUNT,
"Too many unvalidated images",
);
const buffer = dataUrlToWebpBuffer(value.dataUrl);
const uploadedFileLocation = await uploadStreamToS3(
Readable.from(buffer),
`img-${Date.now()}-${shortNanoid()}.webp`,
);
invariant(uploadedFileLocation, "Image upload failed");
const fileName = basename(uploadedFileLocation);
const shouldAutoValidate = user.roles.includes("SUPPORTER");
const img = await ImageRepository.insert({
submitterUserId: user.id,
url: fileName,
validatedAt: shouldAutoValidate
? dateToDatabaseTimestamp(new Date())
: null,
});
return img.id;
}
function dataUrlToWebpBuffer(dataUrl: string) {
const base64 = dataUrl.slice(dataUrl.indexOf(",") + 1);
const buffer = Buffer.from(base64, "base64");
invariant(isWebp(buffer), "Submitted image is not a valid webp");
return buffer;
}
/** Verifies the buffer's magic bytes match the webp container (`RIFF....WEBP`). */
function isWebp(buffer: Buffer) {
return (
buffer.length > 12 &&
buffer.toString("ascii", 0, 4) === "RIFF" &&
buffer.toString("ascii", 8, 12) === "WEBP"
);
}

View File

@@ -112,6 +112,8 @@ export function findByCustomUrl(
"Team.tag",
"Team.customUrl",
"Team.customTheme",
"Team.avatarImgId",
"Team.bannerImgId",
concatUserSubmittedImagePrefix(eb.ref("AvatarImage.url")).as("avatarUrl"),
concatUserSubmittedImagePrefix(eb.ref("BannerImage.url")).as("bannerUrl"),
jsonArrayFrom(
@@ -309,23 +311,53 @@ export async function update({
bio,
bsky,
tag,
}: Pick<Insertable<Tables["Team"]>, "id" | "name" | "bio" | "bsky" | "tag">) {
avatarImgId,
bannerImgId,
}: Pick<
Insertable<Tables["Team"]>,
"id" | "name" | "bio" | "bsky" | "tag" | "avatarImgId" | "bannerImgId"
>) {
const customUrl = mySlugify(name);
const team = await db
.updateTable("AllTeam")
.set({
name,
customUrl,
bio,
bsky,
tag,
})
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
return db.transaction().execute(async (trx) => {
const current = await trx
.selectFrom("Team")
.select(["avatarImgId", "bannerImgId"])
.where("id", "=", id)
.executeTakeFirst();
return team;
// images that got removed or replaced are no longer referenced by anything,
// so their submitted image rows are cleaned up
const orphanedImageIds: number[] = [];
if (current?.avatarImgId && current.avatarImgId !== avatarImgId) {
orphanedImageIds.push(current.avatarImgId);
}
if (current?.bannerImgId && current.bannerImgId !== bannerImgId) {
orphanedImageIds.push(current.bannerImgId);
}
if (orphanedImageIds.length > 0) {
await trx
.deleteFrom("UnvalidatedUserSubmittedImage")
.where("id", "in", orphanedImageIds)
.execute();
}
return trx
.updateTable("AllTeam")
.set({
name,
customUrl,
bio,
bsky,
tag,
avatarImgId,
bannerImgId,
})
.where("id", "=", id)
.returningAll()
.executeTakeFirstOrThrow();
});
}
export async function updateCustomTheme({
@@ -422,37 +454,6 @@ export function del(teamId: number) {
});
}
export function removeTeamImage(
teamId: number,
imageType: "avatar" | "banner",
) {
const imageIdField = imageType === "avatar" ? "avatarImgId" : "bannerImgId";
return db.transaction().execute(async (trx) => {
const team = await trx
.selectFrom("Team")
.select(imageIdField)
.where("id", "=", teamId)
.executeTakeFirst();
const imageId = team?.[imageIdField];
if (imageId) {
await trx
.deleteFrom("UnvalidatedUserSubmittedImage")
.where("id", "=", imageId)
.execute();
}
await trx
.updateTable("AllTeam")
.set({
[imageIdField]: null,
})
.where("id", "=", teamId)
.execute();
});
}
export function resetInviteCode(teamId: number) {
return db
.updateTable("AllTeam")

View File

@@ -3,15 +3,10 @@ import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db } from "~/db/sql";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
import type { editTeamActionSchema } from "../team-schemas.server";
import { action as _editTeamProfileAction } from "./t.$customUrl.edit.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
@@ -19,7 +14,7 @@ const createTeamAction = wrappedAction<typeof createTeamSchema>({
isJsonSubmission: true,
});
const editTeamProfileAction = wrappedAction<typeof editTeamSchema>({
const editTeamProfileAction = wrappedAction<typeof editTeamActionSchema>({
action: _editTeamProfileAction,
isJsonSubmission: true,
});
@@ -30,6 +25,8 @@ const DEFAULT_EDIT_FIELDS = {
bio: "",
bsky: "",
tag: "",
logo: null,
banner: null,
} as const;
const VALID_CUSTOM_THEME = {
@@ -122,7 +119,7 @@ describe("team page editing", () => {
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
expect(response.fieldErrors["newValue.baseHue"]).toBeTruthy();
});
it("preserves an existing custom theme when editing the team profile", async () => {
@@ -147,4 +144,65 @@ describe("team page editing", () => {
expect(team?.customTheme).toEqual(expectedStoredTheme());
expect(team?.bio).toBe("Updated bio");
});
const addTeamAvatar = async () => {
const image = await db
.insertInto("UnvalidatedUserSubmittedImage")
.values({
url: "https://example.com/test-avatar.jpg",
submitterUserId: REGULAR_USER_TEST_ID,
})
.returning("id")
.executeTakeFirstOrThrow();
await db
.updateTable("AllTeam")
.set({ avatarImgId: image.id })
.where("customUrl", "=", "team-1")
.execute();
return image.id;
};
const imageExists = async (id: number) =>
Boolean(
await db
.selectFrom("UnvalidatedUserSubmittedImage")
.select("id")
.where("id", "=", id)
.executeTakeFirst(),
);
it("deletes the submitted image row when an image is removed while editing", async () => {
const imageId = await addTeamAvatar();
await editTeamProfileAction(
{ ...DEFAULT_EDIT_FIELDS },
{ user: "regular", params: { customUrl: "team-1" } },
);
const team = await TeamRepository.findByCustomUrl("team-1");
expect(team?.avatarImgId).toBeNull();
expect(await imageExists(imageId)).toBe(false);
});
it("keeps the submitted image row when an existing image is unchanged", async () => {
const imageId = await addTeamAvatar();
await editTeamProfileAction(
{
...DEFAULT_EDIT_FIELDS,
logo: {
type: "EXISTING",
imgId: imageId,
url: "https://example.com/test-avatar.jpg",
},
},
{ user: "regular", params: { customUrl: "team-1" } },
);
const team = await TeamRepository.findByCustomUrl("team-1");
expect(team?.avatarImgId).toBe(imageId);
expect(await imageExists(imageId)).toBe(true);
});
});

View File

@@ -1,21 +1,14 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { parseFormDataWithImages } from "~/form/parse.server";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { mySlugify, teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import {
canAddCustomizedColors,
isTeamManager,
isTeamOwner,
} from "../team-utils";
import { editTeamActionSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamManager } from "../team-utils";
export const action: ActionFunction = async ({ request, params }) => {
const user = requireUser();
@@ -28,18 +21,17 @@ export const action: ActionFunction = async ({ request, params }) => {
"You are not a team manager",
);
const data = await parseRequestPayload({
const result = await parseFormDataWithImages({
request,
schema: editTeamSchema,
schema: editTeamActionSchema,
});
if (data._action.includes("DELETE")) {
errorToastIfFalsy(
isTeamOwner({ team, user }) || user.roles.includes("ADMIN"),
"You are not the team owner",
);
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "UPDATE_CUSTOM_THEME": {
errorToastIfFalsy(
@@ -47,49 +39,32 @@ export const action: ActionFunction = async ({ request, params }) => {
"Team does not have custom theme access",
);
const customTheme = data.newValue
? clampThemeToGamut(data.newValue)
: null;
await TeamRepository.updateCustomTheme({
id: team.id,
customTheme,
customTheme: data.newValue ? clampThemeToGamut(data.newValue) : null,
});
return { ok: true };
}
case "DELETE_TEAM": {
await TeamRepository.del(team.id);
throw redirect("/");
}
case "DELETE_AVATAR": {
await TeamRepository.removeTeamImage(team.id, "avatar");
throw redirect(teamPage(team.customUrl));
}
case "DELETE_BANNER": {
await TeamRepository.removeTeamImage(team.id, "banner");
throw redirect(teamPage(team.customUrl));
}
case "EDIT": {
const newCustomUrl = mySlugify(data.name);
errorToastIfFalsy(
newCustomUrl.length > 0,
"Team name can't be only special characters",
);
const teams = await TeamRepository.findAllUndisbanded();
const duplicateTeam = teams.find(
(t) => t.customUrl === newCustomUrl && t.customUrl !== team.customUrl,
);
if (duplicateTeam) {
return { errors: ["forms:errors.duplicateName"] };
return { fieldErrors: { name: "forms:errors.duplicateName" } };
}
const updatedTeam = await TeamRepository.update({
id: team.id,
...data,
name: data.name,
bio: data.bio,
bsky: data.bsky,
tag: data.tag,
avatarImgId: data.logo,
bannerImgId: data.banner,
});
throw redirect(teamPage(updatedTeam.customUrl));

View File

@@ -1,4 +1,5 @@
import type { ActionFunction } from "react-router";
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import {
errorToastIfFalsy,
@@ -54,6 +55,15 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "DELETE_TEAM": {
errorToastIfFalsy(
isTeamOwner({ user, team }) || user.roles.includes("ADMIN"),
"You are not the team owner",
);
await TeamRepository.del(team.id);
throw redirect("/");
}
default: {
assertUnreachable(data);
}

View File

@@ -1,27 +1,25 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type { createTeamSchema } from "../team-schemas";
import type { editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema, editTeamFormSchema } from "../team-schemas";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
const editTeamAction = wrappedAction<typeof editTeamFormSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
const DEFAULT_FIELDS = {
tag: null,
bsky: null,
bio: null,
logo: null,
banner: null,
} as any;
describe("team creation", () => {
@@ -45,13 +43,13 @@ describe("team creation", () => {
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(res.errors[0]).toBe("forms:errors.duplicateName");
expect(res.fieldErrors.name).toBe("forms:errors.duplicateName");
});
it("prevents editing team name to only special characters", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
const response = await editTeamAction(
const res = await editTeamAction(
{
_action: "EDIT",
name: "𝓢𝓲𝓵",
@@ -60,6 +58,6 @@ describe("team creation", () => {
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
expect(res.fieldErrors.name).toBe("forms:errors.noOnlySpecialCharacters");
});
});

View File

@@ -1,27 +1,17 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Form, Link, useFetcher, useLoaderData } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { FormErrors } from "~/components/FormErrors";
import { FormMessage } from "~/components/FormMessage";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main, mainStyles } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { TeamGoBackButton } from "~/features/team/components/TeamGoBackButton";
import { SendouForm } from "~/form/SendouForm";
import type { ThemeInput } from "~/utils/oklch-gamut";
import { metaTags } from "~/utils/remix";
import { uploadImagePage } from "~/utils/urls";
import { action } from "../actions/t.$customUrl.edit.server";
import { loader } from "../loaders/t.$customUrl.edit.server";
import styles from "../team.module.css";
import { TEAM } from "../team-constants";
import { isTeamOwner } from "../team-utils";
import { editTeamFormSchema } from "../team-schemas";
export { action, loader };
@@ -34,43 +24,51 @@ export const meta: MetaFunction = (args) => {
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
const user = useUser();
const { team, canAddCustomizedColors } = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<TeamGoBackButton />
<div className={mainStyles.narrow}>
{isTeamOwner({ team, user }) ? (
<FormWithConfirm
dialogHeading={t("team:deleteTeam.header", { teamName: team.name })}
fields={[["_action", "DELETE_TEAM"]]}
>
<SendouButton
className="ml-auto"
variant="minimal-destructive"
data-testid="delete-team-button"
>
{t("team:actionButtons.deleteTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
<ImageRemoveButtons />
<NameInput />
<TagInput />
<BlueskyInput />
<BioTextarea />
<SubmitButton
className="mt-4"
_action="EDIT"
testId="edit-team-submit-button"
>
{t("common:actions.submit")}
</SubmitButton>
<FormErrors namespace="team" />
</Form>
<SendouForm
schema={editTeamFormSchema}
title={t("team:editTeam.header", { teamName: team.name })}
defaultValues={{
name: team.name,
tag: team.tag ?? "",
bsky: team.bsky ?? "",
bio: team.bio ?? "",
logo:
team.avatarImgId && team.avatarUrl
? {
type: "EXISTING",
imgId: team.avatarImgId,
url: team.avatarUrl,
}
: null,
banner:
team.bannerImgId && team.bannerUrl
? {
type: "EXISTING",
imgId: team.bannerImgId,
url: team.bannerUrl,
}
: null,
}}
submitButtonText={t("common:actions.submit")}
submitButtonTestId="edit-team-submit-button"
>
{({ FormField }) => (
<>
<FormField name="name" />
<FormField name="tag" />
<FormField name="bsky" />
<FormField name="bio" />
<FormField name="logo" />
<FormField name="banner" />
</>
)}
</SendouForm>
{canAddCustomizedColors ? (
<>
<Divider className={styles.formDivider} smallText>
@@ -84,171 +82,6 @@ export default function EditTeamPage() {
);
}
function ImageUploadLinks() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label>{t("team:forms.fields.uploadImages")}</Label>
<ol className={styles.imageLinksList}>
<li>
<Link
to={uploadImagePage({
type: "team-pfp",
teamCustomUrl: team.customUrl,
})}
>
{t("team:forms.fields.uploadImages.pfp")}
</Link>
</li>
<li>
<Link
to={uploadImagePage({
type: "team-banner",
teamCustomUrl: team.customUrl,
})}
>
{t("team:forms.fields.uploadImages.banner")}
</Link>
</li>
</ol>
</div>
);
}
function ImageRemoveButtons() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
return team.avatarUrl || team.bannerUrl ? (
<div>
<Label>{t("team:forms.fields.removeImages")}</Label>
<ol className={styles.imageLinksList}>
{team.avatarUrl ? (
<li>
<FormWithConfirm
dialogHeading={t("team:deleteTeam.profilePicture.header", {
teamName: team.name,
})}
fields={[["_action", "DELETE_AVATAR"]]}
submitButtonText={t("common:actions.remove")}
>
<SendouButton className="ml-auto" variant="minimal-destructive">
{t("team:actionButtons.deleteTeam.profilePicture")}
</SendouButton>
</FormWithConfirm>
</li>
) : null}
{team.bannerUrl ? (
<li>
<FormWithConfirm
dialogHeading={t("team:deleteTeam.banner.header", {
teamName: team.name,
})}
fields={[["_action", "DELETE_BANNER"]]}
submitButtonText={t("common:actions.remove")}
>
<SendouButton className="ml-auto" variant="minimal-destructive">
{t("team:actionButtons.deleteTeam.banner")}
</SendouButton>
</FormWithConfirm>
</li>
) : null}
</ol>
</div>
) : null;
}
function NameInput() {
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
return (
<div>
<Label htmlFor="title" required>
{t("common:forms.name")}
</Label>
<input
id="name"
name="name"
required
minLength={TEAM.NAME_MIN_LENGTH}
maxLength={TEAM.NAME_MAX_LENGTH}
defaultValue={team.name}
data-testid="name-input"
/>
<FormMessage type="info">{t("team:forms.info.name")}</FormMessage>
</div>
);
}
function TagInput() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.tag ?? "");
return (
<div>
<Label htmlFor="tag">{t("team:forms.fields.tag")}</Label>
<input
id="tag"
name="tag"
maxLength={TEAM.TAG_MAX_LENGTH}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<FormMessage type="info">{t("team:forms.info.tag")}</FormMessage>
</div>
);
}
function BlueskyInput() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.bsky ?? "");
return (
<div>
<Label htmlFor="bsky">{t("team:forms.fields.teamBsky")}</Label>
<Input
leftAddon="https://bsky.app/profile/"
id="bsky"
name="bsky"
maxLength={TEAM.BSKY_MAX_LENGTH}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
}
function BioTextarea() {
const { t } = useTranslation(["team"]);
const { team } = useLoaderData<typeof loader>();
const [value, setValue] = React.useState(team.bio ?? "");
return (
<div className="w-full">
<Label
htmlFor="bio"
valueLimits={{ current: value.length, max: TEAM.BIO_MAX_LENGTH }}
>
{t("team:forms.fields.bio")}
</Label>
<textarea
id="bio"
name="bio"
value={value}
onChange={(e) => setValue(e.target.value)}
maxLength={TEAM.BIO_MAX_LENGTH}
data-testid="bio-textarea"
className="w-full"
/>
</div>
);
}
function TeamCustomThemeSelector() {
const { customTheme, canAddCustomizedColors } =
useLoaderData<typeof loader>();

View File

@@ -1,13 +1,13 @@
import { SquarePen, Star, Users } from "lucide-react";
import { LogOut, Menu, SquarePen, Star, Trash2, Users } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher, useMatches } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu";
import { WeaponImage } from "~/components/Image";
import { Placement } from "~/components/Placement";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
import { useHasRole } from "~/modules/permissions/hooks";
@@ -64,38 +64,8 @@ function ActionButtons() {
return null;
}
const isMainTeam = team.members.find(
(member) => user?.id === member.id && member.isMainTeam,
);
return (
<div className={styles.actionButtons}>
{isTeamMember({ user, team }) && !isMainTeam ? (
<ChangeMainTeamButton />
) : null}
{isTeamMember({ user, team }) && team.members.length > 1 ? (
<FormWithConfirm
dialogHeading={`${t(
isTeamOwner({ user, team })
? "team:leaveTeam.header.newOwner"
: "team:leaveTeam.header",
{
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
},
)}`}
submitButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
<SendouButton
size="small"
variant="destructive"
data-testid="leave-team-button"
>
{t("team:actionButtons.leaveTeam")}
</SendouButton>
</FormWithConfirm>
) : null}
{isTeamManager({ user, team }) || isAdmin ? (
<LinkButton
size="small"
@@ -120,26 +90,152 @@ function ActionButtons() {
{t("team:actionButtons.editTeam")}
</LinkButton>
) : null}
<TeamActionsMenu team={team} />
</div>
);
}
function ChangeMainTeamButton() {
const { t } = useTranslation(["team"]);
function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) {
const { t } = useTranslation(["common", "team"]);
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const fetcher = useFetcher();
const [confirming, setConfirming] = React.useState<"LEAVE" | "DELETE" | null>(
null,
);
const isMainTeam = team.members.some(
(member) => user?.id === member.id && member.isMainTeam,
);
const showMainTeamIndicator = isTeamMember({ user, team }) && isMainTeam;
const canMakeMainTeam = isTeamMember({ user, team }) && !isMainTeam;
const canLeaveTeam = isTeamMember({ user, team }) && team.members.length > 1;
const canDeleteTeam = isTeamOwner({ user, team }) || isAdmin;
if (
!showMainTeamIndicator &&
!canMakeMainTeam &&
!canLeaveTeam &&
!canDeleteTeam
) {
return null;
}
const submitAction = (action: string) => {
fetcher.submit({ _action: action }, { method: "post" });
setConfirming(null);
};
return (
<fetcher.Form method="post">
<SubmitButton
_action="MAKE_MAIN_TEAM"
size="small"
variant="outlined"
icon={<Star />}
testId="make-main-team-button"
<>
<SendouMenu
trigger={
<SendouButton
size="small"
variant="outlined"
icon={<Menu />}
aria-label={t("team:actionButtons.teamActions")}
testId="team-actions-menu-button"
/>
}
>
{t("team:actionButtons.makeMainTeam")}
</SubmitButton>
</fetcher.Form>
{showMainTeamIndicator ? (
<SendouMenuItem
icon={<Star />}
isActive
isDisabled
data-testid="main-team-indicator"
>
{t("team:actionButtons.mainTeam")}
</SendouMenuItem>
) : null}
{canMakeMainTeam ? (
<SendouMenuItem
icon={<Star />}
onAction={() => submitAction("MAKE_MAIN_TEAM")}
data-testid="make-main-team-button"
>
{t("team:actionButtons.makeMainTeam")}
</SendouMenuItem>
) : null}
{canLeaveTeam ? (
<SendouMenuItem
icon={<LogOut />}
onAction={() => setConfirming("LEAVE")}
data-testid="leave-team-button"
>
{t("team:actionButtons.leaveTeam")}
</SendouMenuItem>
) : null}
{canDeleteTeam ? (
<SendouMenuItem
icon={<Trash2 />}
isDestructive
onAction={() => setConfirming("DELETE")}
data-testid="delete-team-button"
>
{t("team:actionButtons.deleteTeam")}
</SendouMenuItem>
) : null}
</SendouMenu>
<SendouDialog
isOpen={confirming === "LEAVE"}
onClose={() => setConfirming(null)}
onOpenChange={() => setConfirming(null)}
isDismissable
>
<ConfirmActionContent
heading={t(
isTeamOwner({ user, team })
? "team:leaveTeam.header.newOwner"
: "team:leaveTeam.header",
{
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
},
)}
buttonText={t("team:actionButtons.leaveTeam.confirm")}
onConfirm={() => submitAction("LEAVE_TEAM")}
/>
</SendouDialog>
<SendouDialog
isOpen={confirming === "DELETE"}
onClose={() => setConfirming(null)}
onOpenChange={() => setConfirming(null)}
isDismissable
>
<ConfirmActionContent
heading={t("team:deleteTeam.header", { teamName: team.name })}
buttonText={t("common:actions.delete")}
onConfirm={() => submitAction("DELETE_TEAM")}
/>
</SendouDialog>
</>
);
}
function ConfirmActionContent({
heading,
buttonText,
onConfirm,
}: {
heading: string;
buttonText: string;
onConfirm: () => void;
}) {
return (
<div className="stack md">
<h2 className="text-md text-center">{heading}</h2>
<div className="stack horizontal md justify-center mt-2">
<SendouButton
variant="destructive"
onPress={onConfirm}
data-testid="confirm-button"
>
{buttonText}
</SendouButton>
</div>
</div>
);
}

View File

@@ -9,13 +9,9 @@ import {
} from "~/utils/Test";
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import * as TeamRepository from "../TeamRepository.server";
import type { createTeamSchema } from "../team-schemas";
import type {
editTeamSchema,
teamProfilePageActionSchema,
} from "../team-schemas.server";
import type { teamProfilePageActionSchema } from "../team-schemas.server";
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
@@ -25,10 +21,6 @@ const teamPageAction = wrappedAction<typeof teamProfilePageActionSchema>({
action: _teamPageAction,
isJsonSubmission: true,
});
const editTeamAction = wrappedAction<typeof editTeamSchema>({
action: _editTeamAction,
isJsonSubmission: true,
});
async function loadTeams() {
const teams = await TeamRepository.teamsByMemberUserId(REGULAR_USER_TEST_ID);
@@ -94,7 +86,7 @@ describe("Secondary teams", () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await editTeamAction(
await teamPageAction(
{
_action: "DELETE_TEAM",
},
@@ -110,6 +102,26 @@ describe("Secondary teams", () => {
expect(secondaryTeams).toHaveLength(0);
});
it("only the team owner (or admin) can delete a team", async () => {
await createTeamAction({ name: "Team 1" }, { user: "admin" });
await TeamRepository.addNewTeamMember({
userId: REGULAR_USER_TEST_ID,
teamId: 1,
maxTeamsAllowed: 2,
});
const response = await teamPageAction(
{ _action: "DELETE_TEAM" },
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
const team = await TeamRepository.findByCustomUrl("team-1");
expect(team).toBeTruthy();
});
it("when leaving the main team, the secondary team becomes main", async () => {
// has to be made by "admin" because can't leave team you own
await createTeamAction({ name: "Team 1" }, { user: "admin" });
@@ -177,95 +189,4 @@ describe("Secondary teams", () => {
const { secondaryTeams } = await loadTeams();
expect(secondaryTeams).toHaveLength(2);
});
const createTeamWithImage = async (imageType: "avatar" | "banner") => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
const imageId = await db
.insertInto("UnvalidatedUserSubmittedImage")
.values({
url: `https://example.com/test-${imageType}.jpg`,
submitterUserId: REGULAR_USER_TEST_ID,
})
.returning("id")
.executeTakeFirstOrThrow();
const imageField = imageType === "avatar" ? "avatarImgId" : "bannerImgId";
await db
.updateTable("AllTeam")
.set({ [imageField]: imageId.id })
.where("customUrl", "=", "team-1")
.execute();
return imageId.id;
};
it("deletes team avatar", async () => {
const imageId = await createTeamWithImage("avatar");
await editTeamAction(
{ _action: "DELETE_AVATAR" },
{ user: "regular", params: { customUrl: "team-1" } },
);
const team = await db
.selectFrom("Team")
.select("avatarImgId")
.where("customUrl", "=", "team-1")
.executeTakeFirst();
expect(team?.avatarImgId).toBeNull();
const image = await db
.selectFrom("UnvalidatedUserSubmittedImage")
.select("id")
.where("id", "=", imageId)
.executeTakeFirst();
expect(image).toBeUndefined();
});
it("deletes team banner", async () => {
const imageId = await createTeamWithImage("banner");
await editTeamAction(
{ _action: "DELETE_BANNER" },
{ user: "regular", params: { customUrl: "team-1" } },
);
const team = await db
.selectFrom("Team")
.select("bannerImgId")
.where("customUrl", "=", "team-1")
.executeTakeFirst();
expect(team?.bannerImgId).toBeNull();
const image = await db
.selectFrom("UnvalidatedUserSubmittedImage")
.select("id")
.where("id", "=", imageId)
.executeTakeFirst();
expect(image).toBeUndefined();
});
it("only team owner can delete images", async () => {
await createTeamWithImage("avatar");
await db
.insertInto("User")
.values({
discordName: "otheruser",
discordId: "999",
})
.execute();
const response = await editTeamAction(
{ _action: "DELETE_AVATAR" },
{ user: "regular", params: { customUrl: "team-1" } },
);
expect(response.status).toBe(302);
});
});

View File

@@ -1,9 +1,9 @@
import { z } from "zod";
import { mySlugify } from "~/utils/urls";
import { _action, falsyToNull, id, themeInputSchema } from "~/utils/zod";
import { _action, id, themeInputSchema } from "~/utils/zod";
import * as TeamRepository from "./TeamRepository.server";
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
import { createTeamSchema } from "./team-schemas";
import { TEAM_MEMBER_ROLES } from "./team-constants";
import { createTeamSchema, editTeamFormSchema } from "./team-schemas";
export const createTeamSchemaServer = z.object({
...createTeamSchema.shape,
@@ -27,41 +27,23 @@ export const teamProfilePageActionSchema = z.union([
z.object({
_action: _action("MAKE_MAIN_TEAM"),
}),
z.object({
_action: _action("DELETE_TEAM"),
}),
]);
const deleteActionsSchema = z.object({
_action: z.union([
_action("DELETE_TEAM"),
_action("DELETE_AVATAR"),
_action("DELETE_BANNER"),
]),
const updateTeamCustomThemeSchema = z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: z.preprocess(
(val) => (!val || val === "null" ? null : val),
themeInputSchema.nullable(),
),
});
export const editTeamSchema = z.union([
deleteActionsSchema,
z.object({
_action: _action("UPDATE_CUSTOM_THEME"),
newValue: z.preprocess(
(val) => (!val || val === "null" ? null : val),
themeInputSchema.nullable(),
),
}),
z.object({
_action: _action("EDIT"),
name: z.string().min(TEAM.NAME_MIN_LENGTH).max(TEAM.NAME_MAX_LENGTH),
bio: z.preprocess(
falsyToNull,
z.string().max(TEAM.BIO_MAX_LENGTH).nullable(),
),
bsky: z.preprocess(
falsyToNull,
z.string().max(TEAM.BSKY_MAX_LENGTH).nullable(),
),
tag: z.preprocess(
falsyToNull,
z.string().max(TEAM.TAG_MAX_LENGTH).nullable(),
),
}),
/** Every payload the team edit route action accepts, discriminated by `_action`. */
export const editTeamActionSchema = z.union([
editTeamFormSchema,
updateTeamCustomThemeSchema,
]);
export const manageRosterSchema = z.union([

View File

@@ -1,17 +1,52 @@
import { z } from "zod";
import { textFieldRequired } from "~/form/fields";
import {
image,
stringConstant,
textAreaOptional,
textFieldOptional,
textFieldRequired,
} from "~/form/fields";
import { mySlugify } from "~/utils/urls";
import { TEAM } from "./team-constants";
const teamNameValidate = {
func: (teamName: string) =>
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
message: "forms:errors.noOnlySpecialCharacters",
} as const;
export const createTeamSchema = z.object({
name: textFieldRequired({
label: "labels.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: {
func: (teamName) =>
mySlugify(teamName).length > 0 && mySlugify(teamName) !== "new",
message: "forms:errors.noOnlySpecialCharacters",
},
validate: teamNameValidate,
}),
});
export const editTeamFormSchema = z.object({
_action: stringConstant("EDIT"),
name: textFieldRequired({
label: "labels.name",
bottomText: "bottomTexts.name",
minLength: TEAM.NAME_MIN_LENGTH,
maxLength: TEAM.NAME_MAX_LENGTH,
validate: teamNameValidate,
}),
tag: textFieldOptional({
label: "labels.tag",
bottomText: "bottomTexts.tag",
maxLength: TEAM.TAG_MAX_LENGTH,
}),
bsky: textFieldOptional({
label: "labels.teamBsky",
leftAddon: "https://bsky.app/profile/",
maxLength: TEAM.BSKY_MAX_LENGTH,
}),
bio: textAreaOptional({
label: "labels.bio",
maxLength: TEAM.BIO_MAX_LENGTH,
}),
logo: image({ label: "labels.logo" }),
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
});

View File

@@ -7,6 +7,7 @@ import { BadgesFormField } from "./fields/BadgesFormField";
import { DatetimeFormField } from "./fields/DatetimeFormField";
import { DualSelectFormField } from "./fields/DualSelectFormField";
import { FieldsetFormField } from "./fields/FieldsetFormField";
import { ImageFormField } from "./fields/ImageFormField";
import { InputFormField } from "./fields/InputFormField";
import {
CheckboxGroupFormField,
@@ -24,6 +25,7 @@ import {
type WeaponPoolItem,
} from "./fields/WeaponPoolFormField";
import { WeaponSelectFormField } from "./fields/WeaponSelectFormField";
import type { ImageFieldValue } from "./image-field";
import { useOptionalFormFieldContext } from "./SendouForm";
import type {
ArrayItemRenderContext,
@@ -289,6 +291,17 @@ export function FormField({
);
}
if (formField.type === "image") {
return (
<ImageFormField
{...commonProps}
{...formField}
value={value as ImageFieldValue}
onChange={handleChange as (v: ImageFieldValue) => void}
/>
);
}
if (formField.type === "custom") {
if (!children) {
throw new Error("Custom form field requires children render function");

View File

@@ -11,6 +11,7 @@ import {
timeString,
weaponSplId,
} from "~/utils/zod";
import { imageValue } from "./image-field";
import type {
BadgeOption,
FieldWithOptions,
@@ -79,6 +80,20 @@ function prefixItems<V extends string>(
}));
}
export function image(args: {
label: FormsTranslationKey;
dimensions?: "logo" | "thick-banner" | { width: number; height: number };
}) {
// clone so each field gets its own registry entry (the shared `imageValue`
// instance would otherwise have its metadata overwritten by later fields)
return imageValue.clone().register(formRegistry, {
label: prefixKey(args.label),
dimensions: args.dimensions ?? "logo",
type: "image",
initialValue: null,
});
}
export function customField<T extends z.ZodType>(
args: Omit<Extract<FormField, { type: "custom" }>, "type">,
schema: T,

View File

@@ -0,0 +1,13 @@
.preview {
width: 144px;
height: 144px;
border-radius: 50%;
object-fit: cover;
&.banner {
width: 100%;
height: auto;
max-width: 320px;
border-radius: var(--radius-box);
}
}

View File

@@ -0,0 +1,96 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { SendouButton } from "~/components/elements/Button";
import { logger } from "~/utils/logger";
import {
type ImageFieldValue,
resolveImageFieldDimensions,
} from "../image-field";
import type { FormFieldProps } from "../types";
import { FormFieldWrapper } from "./FormFieldWrapper";
import styles from "./ImageFormField.module.css";
type ImageFormFieldProps = Omit<FormFieldProps<"image">, "onBlur"> & {
value: ImageFieldValue;
onChange: (value: ImageFieldValue) => void;
};
export function ImageFormField({
name,
label,
dimensions,
error,
value,
onChange,
}: ImageFormFieldProps) {
const id = React.useId();
const { t } = useTranslation(["common"]);
const resolvedDimensions = resolveImageFieldDimensions(dimensions);
const previewUrl =
value?.type === "EXISTING"
? value.url
: value?.type === "NEW"
? value.dataUrl
: null;
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const uploadedFile = event.target.files?.[0];
if (!uploadedFile) return;
new Compressor(uploadedFile, {
width: resolvedDimensions.width,
height: resolvedDimensions.height,
maxWidth: resolvedDimensions.width,
maxHeight: resolvedDimensions.height,
resize: "cover",
mimeType: "image/webp",
success(result) {
const reader = new FileReader();
reader.onload = () =>
onChange({ type: "NEW", dataUrl: reader.result as string });
reader.onerror = () => logger.error("Failed to read compressed image");
reader.readAsDataURL(result);
},
error(err) {
logger.error(err.message);
},
});
};
const isBanner =
dimensions === "thick-banner" ||
(typeof dimensions === "object" && dimensions.width > dimensions.height);
return (
<FormFieldWrapper id={id} name={name} label={label} error={error}>
<div className="stack sm items-start">
{previewUrl ? (
<img
src={previewUrl}
alt=""
className={clsx(styles.preview, { [styles.banner]: isBanner })}
/>
) : null}
{value ? (
<SendouButton
variant="minimal-destructive"
size="small"
onPress={() => onChange(null)}
>
{t("common:actions.remove")}
</SendouButton>
) : (
<input
id={id}
type="file"
accept="image/png, image/jpeg, image/webp"
onChange={handleFileChange}
/>
)}
</div>
</FormFieldWrapper>
);
}

60
app/form/image-field.ts Normal file
View File

@@ -0,0 +1,60 @@
import { z } from "zod";
import { id } from "~/utils/zod";
/** Prefix every {@link imageValue} `NEW` data URL must start with (client compresses to webp). */
const IMAGE_FIELD_WEBP_DATA_URL_PREFIX = "data:image/webp;base64,";
/**
* Hard ceiling for a `NEW` data URL's length. Caps the JSON body size so a malicious or
* oversized payload can't bloat the request. A `thick-banner` webp base64-encodes to ~200KB,
* so this leaves comfortable headroom.
*/
const IMAGE_FIELD_MAX_DATA_URL_LENGTH = 3_000_000;
/**
* JSON-serializable value of a SendouForm `image` field. Covers every state an edit form needs:
* `null` (none / removed), an unchanged `EXISTING` image (only the id reference + a preview url
* ride in JSON, never bytes), or a newly picked `NEW` image as a base64 webp data URL.
*/
export const imageValue = z
.union([
z.object({
type: z.literal("EXISTING"),
imgId: id,
url: z.string(),
}),
z.object({
type: z.literal("NEW"),
dataUrl: z
.string()
.max(IMAGE_FIELD_MAX_DATA_URL_LENGTH)
.startsWith(IMAGE_FIELD_WEBP_DATA_URL_PREFIX),
}),
])
.nullable();
export type ImageFieldValue = z.infer<typeof imageValue>;
export type ImageFieldDimensions =
| "logo"
| "thick-banner"
| { width: number; height: number };
const IMAGE_FIELD_DIMENSION_PRESETS = {
logo: { width: 400, height: 400 },
"thick-banner": { width: 1000, height: 500 },
} as const;
/** Resolves an `image` field's `dimensions` (preset name or explicit numbers) to a `{ width, height }`. */
export function resolveImageFieldDimensions(
dimensions?: ImageFieldDimensions,
): {
width: number;
height: number;
} {
if (!dimensions || typeof dimensions === "string") {
return IMAGE_FIELD_DIMENSION_PRESETS[dimensions ?? "logo"];
}
return dimensions;
}

View File

@@ -1,5 +1,9 @@
import type { z } from "zod";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { imageFieldValueToImgId } from "~/features/img-upload/image-field.server";
import { formDataToObject } from "~/utils/remix.server";
import { formRegistry } from "./fields";
import type { ImageFieldValue } from "./image-field";
export type ParseResult<T> =
| { success: true; data: T }
@@ -38,3 +42,60 @@ export async function parseFormData<T extends z.ZodTypeAny>({
return { success: false, fieldErrors };
}
/** Image field values collapse to their stored id; everything else passes through. */
type ResolvedImages<T> = T extends unknown
? { [K in keyof T]: T[K] extends ImageFieldValue ? number | null : T[K] }
: never;
/**
* Like {@link parseFormData}, but additionally resolves every `image()` field in the schema to the
* image id to store on the consuming FK column (`number | null`) via {@link imageFieldValueToImgId}
* — uploading newly picked images, keeping unchanged ones, and clearing removed ones. The schema
* may be a single object or a union of objects (e.g. an `_action` discriminated form). The
* consuming action receives a plain id per image field and only writes it to its own entity.
*/
export async function parseFormDataWithImages<T extends z.ZodTypeAny>({
request,
schema,
}: {
request: Request;
schema: T;
}): Promise<ParseResult<ResolvedImages<z.infer<T>>>> {
const result = await parseFormData({ request, schema });
if (!result.success) return result;
const user = requireUser();
const data = { ...(result.data as Record<string, unknown>) };
for (const key of imageFieldKeys(schema)) {
if (key in data) {
data[key] = await imageFieldValueToImgId({
value: data[key] as ImageFieldValue,
user,
});
}
}
return { success: true, data: data as ResolvedImages<z.infer<T>> };
}
/** Collects the keys of every `image()` field across a schema object or union of objects. */
function imageFieldKeys(schema: z.ZodTypeAny): string[] {
const objects =
schema instanceof z.ZodUnion
? (schema.options as z.ZodObject<z.ZodRawShape>[])
: schema instanceof z.ZodObject
? [schema]
: [];
const keys = new Set<string>();
for (const object of objects) {
for (const [key, fieldSchema] of Object.entries(object.shape)) {
const meta = formRegistry.get(fieldSchema);
if (meta?.type === "image") keys.add(key);
}
}
return [...keys];
}

View File

@@ -1,6 +1,7 @@
import type { z } from "zod";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type forms from "../../locales/en/forms.json";
import type { ImageFieldDimensions } from "./image-field";
export type FormsTranslationKey = keyof typeof forms;
@@ -116,7 +117,7 @@ interface FormFieldMapPool<T extends string> extends FormFieldBase<T> {
interface FormFieldImage<T extends string>
extends Omit<FormFieldBase<T>, "bottomText"> {
dimensions: "logo" | "thick-banner";
dimensions?: ImageFieldDimensions;
}
export interface FormFieldArray<T extends string, S extends z.ZodType>

View File

@@ -55,6 +55,7 @@ export const myFormSchema = z.object({
| `weaponSelectOptional` | Weapon dropdown | `label` |
| `userSearch` | User search autocomplete | `label` |
| `userSearchOptional` | Optional user search | `label` |
| `image` | Small image upload (avatar / logo / banner) | `label` |
| `badges` | Badge selection | `label` |
| `array` | Repeatable field | `field`, `max` |
| `fieldset` | Nested object fields | `fields` |
@@ -308,6 +309,86 @@ const badgeOptions = badges.map((b) => ({
<FormField name="displayBadges" options={badgeOptions} />
```
## Image Field
`image()` is a first-class field for the small "avatar / logo / banner" class of images
(team pfp, team banner, org pfp, calendar/tournament logo). It reuses the existing S3 upload
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.
### Schema
```ts
import { image } from "~/form/fields";
export const editTeamSchema = z.object({
teamId: idConstant(),
logo: image({ label: "labels.logo" }), // logo (default)
banner: image({ label: "labels.banner", dimensions: "thick-banner" }),
cover: image({ label: "labels.cover", dimensions: { width: 800, height: 300 } }),
});
```
`dimensions` is optional, defaulting to `"logo"`. It accepts the `"logo"` / `"thick-banner"`
presets or explicit `{ width, height }` numbers (passed straight to `compressorjs` with
`resize: "cover"`).
### Value model
The field value is a small JSON-serializable union (`ImageFieldValue` from `~/form/image-field`):
```ts
type ImageFieldValue =
| null // none / removed
| { type: "EXISTING"; imgId: number; url: string } // loaded, unchanged (url = preview)
| { type: "NEW"; dataUrl: string } // newly picked, base64 webp
```
- `initialValue` is `null` (the create case).
- Edit forms pass an `EXISTING` value via `SendouForm`'s `defaultValues` (`url` is only for
preview).
- The renderer produces a `NEW` value (client-compressed to webp, base64 data URL) **only** when
the user picks a file, and `null` when they remove — so removal is owned by the field, with no
separate delete action needed. An unchanged `EXISTING` value never re-sends image bytes.
### Server helper
Parse the action with `parseFormDataWithImages` instead of `parseFormData`. It resolves every
`image()` field in the schema to a stored image id (`number | null`) in place, so the action just
writes each id to its own FK column:
```ts
import { parseFormDataWithImages } from "~/form/parse.server";
const result = await parseFormDataWithImages({ request, schema: editTeamSchema });
if (!result.success) return { fieldErrors: result.fieldErrors };
// result.data.logo / result.data.banner are now `number | null`
await TeamRepository.update({
id: data.teamId,
avatarImgId: result.data.logo,
bannerImgId: result.data.banner,
});
```
Per field it resolves `null → null`, `EXISTING → imgId` (no bytes re-sent), `NEW → upload + insert
→ new id`. For a `NEW` value it decodes the base64, validates the bytes are a real webp (magic-byte
check), uploads via `uploadStreamToS3`, and inserts an unvalidated image row (auto-validated for
supporters). The schema may be a single object or an `_action`-discriminated union. (The underlying
per-value helper `imageFieldValueToImgId` from `~/features/img-upload/image-field.server` can still
be called directly if needed.)
### E2E
```ts
await form.setImage("logo", "e2e/fixtures/logo.png");
```
## Custom Fields
Use `customField` for complex UI that doesn't fit standard field types:
@@ -601,6 +682,7 @@ test("fills and submits form", async ({ page }) => {
| `selectUser(name, userName)` | Search and select user |
| `selectWeapons(name, weaponNames)` | Select weapons in weapon pool |
| `setDateTime(name, date)` | Set datetime picker |
| `setImage(name, filePath)` | Upload a file into an image field |
| `submit()` | Click submit button |
| `getLabel(name)` | Get translated label for field |
| `getItemLabel(name, value)` | Get translated label for select item |

View File

@@ -66,6 +66,7 @@ type FormFieldHelpers<T extends z.ZodRawShape> = {
) => Promise<void>;
setDateTime: (name: keyof Inferred<T>, date: Date) => Promise<void>;
setDate: (name: keyof Inferred<T>, date: Date) => Promise<void>;
setImage: (name: keyof Inferred<T>, filePath: string) => Promise<void>;
submit: () => Promise<void>;
getLabel: <K extends keyof Inferred<T>>(name: K) => string;
getItemLabel: (name: keyof Inferred<T>, itemValue: string) => string;
@@ -251,6 +252,11 @@ export function createFormHelpers<T extends z.ZodRawShape>(
await fillSpinbutton("day", date.getDate().toString());
},
async setImage(name, filePath) {
const label = getLabel(String(name));
await page.getByLabel(label).setInputFiles(filePath);
},
async submit() {
await page.getByTestId(submitTestId).click();
},

View File

@@ -1,6 +1,9 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
import { createTeamSchema } from "~/features/team/team-schemas";
import {
createTeamSchema,
editTeamFormSchema,
} from "~/features/team/team-schemas";
import { editTeamPage, teamPage, userPage } from "~/utils/urls";
import {
expect,
@@ -42,16 +45,15 @@ test.describe("Team page", () => {
await page.getByTestId("edit-team-button").click();
await page.getByTestId("name-input").clear();
await page.getByTestId("name-input").fill("Better Alliance Rogue");
const form = createFormHelpers(page, editTeamFormSchema, {
submitTestId: "edit-team-submit-button",
});
await page.getByLabel("Team Bluesky").clear();
await page.getByLabel("Team Bluesky").fill("BetterAllianceRogue");
await form.fill("name", "Better Alliance Rogue");
await form.fill("bsky", "BetterAllianceRogue");
await form.fill("bio", "shorter bio");
await page.getByTestId("bio-textarea").clear();
await page.getByTestId("bio-textarea").fill("shorter bio");
await submit(page, "edit-team-submit-button");
await form.submit();
await expect(page).toHaveURL(/better-alliance-rogue/);
await page.getByText("shorter bio").isVisible();
@@ -86,10 +88,9 @@ test.describe("Team page", () => {
test("deletes team", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("edit-team-button").click();
await page.getByTestId("team-actions-menu-button").click();
await page.getByTestId("delete-team-button").click();
await modalClickConfirmButton(page);
@@ -115,13 +116,15 @@ test.describe("Team page", () => {
await navigate({ page, url: newInviteLink });
await submit(page);
await page.getByTestId("team-actions-menu-button").click();
await page.getByTestId("leave-team-button").click();
await modalClickConfirmButton(page);
await navigate({ page, url: newInviteLink });
await submit(page);
await page.getByTestId("leave-team-button").isVisible();
await page.getByTestId("team-actions-menu-button").click();
await expect(page.getByTestId("leave-team-button")).toBeVisible();
});
test("joins a secondary team, makes main team & leaves making the seconary team the main one", async ({
@@ -137,7 +140,8 @@ test.describe("Team page", () => {
await navigate({ page, url: inviteLink });
await submit(page);
await submit(page, "make-main-team-button");
await page.getByTestId("team-actions-menu-button").click();
await page.getByTestId("make-main-team-button").click();
await navigate({ page, url: userPage({ discordId: ADMIN_DISCORD_ID }) });
@@ -146,6 +150,8 @@ test.describe("Team page", () => {
await page.getByTestId("main-team-link").click();
await page.getByTestId("team-actions-menu-button").click();
await expect(page.getByTestId("main-team-indicator")).toBeVisible();
await page.getByTestId("leave-team-button").click();
await modalClickConfirmButton(page);
@@ -171,19 +177,23 @@ test.describe("Team page", () => {
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: editTeamPage("alliance-rogue") });
await page.getByTestId("bio-textarea").clear();
await page.getByTestId("bio-textarea").fill("from editor");
await submit(page, "edit-team-submit-button");
const editorForm = createFormHelpers(page, editTeamFormSchema, {
submitTestId: "edit-team-submit-button",
});
await editorForm.fill("bio", "from editor");
await editorForm.submit();
await expect(page).toHaveURL(/alliance-rogue/);
await page.getByText("from editor").isVisible();
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("team-actions-menu-button").click();
await page.getByTestId("leave-team-button").click();
await page.getByText("New owner will be N-ZAP").isVisible();
await modalClickConfirmButton(page);
await page.getByTestId("team-actions-menu-button").click();
await isNotVisible(page.getByTestId("leave-team-button"));
});
});

View File

@@ -2,6 +2,8 @@
"submit": "Fremlæg",
"labels.name": "Navn",
"labels.bio": "Biografi",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Opret nyt hold",
"editTeam.header": "",
"teamSearch.placeholder": "Søg efter hold eller spiller...",
"actionButtons.leaveTeam": "Forlad hold",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Forlad hold",

View File

@@ -2,6 +2,8 @@
"submit": "Senden",
"labels.name": "Name",
"labels.bio": "Über mich",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Neues Team erstellen",
"editTeam.header": "",
"teamSearch.placeholder": "Suche nach einem Team oder Spieler...",
"actionButtons.leaveTeam": "Team verlassen",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Verlassen",

View File

@@ -2,6 +2,8 @@
"submit": "Submit",
"labels.name": "Name",
"labels.bio": "Bio",
"labels.logo": "Logo",
"labels.banner": "Banner",
"labels.tag": "Tag",
"labels.teamBsky": "Team Bluesky",
"labels.clockFormat": "Clock format",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Creating a new team",
"editTeam.header": "Editing {{teamName}}",
"teamSearch.placeholder": "Search for a team or player...",
"actionButtons.leaveTeam": "Leave Team",
"actionButtons.makeMainTeam": "Make main team",
"actionButtons.mainTeam": "Main team",
"actionButtons.teamActions": "Team actions",
"leaveTeam.header": "Are you sure you want to leave {{teamName}}?",
"leaveTeam.header.newOwner": "Are you sure you want to leave {{teamName}}? New owner will be {{newOwner}}",
"actionButtons.leaveTeam.confirm": "Leave",

View File

@@ -2,6 +2,8 @@
"submit": "Finalizar",
"labels.name": "Nombre",
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "Etiqueta",
"labels.teamBsky": "Bluesky del equipo",
"labels.clockFormat": "Formato de hora",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Creando nuevo equipo",
"editTeam.header": "",
"teamSearch.placeholder": "Buscar un equipo o jugador...",
"actionButtons.leaveTeam": "Abandonar equipo",
"actionButtons.makeMainTeam": "Hacer equipo principal",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "¿Seguro que quieres abandonar {{teamName}}?",
"leaveTeam.header.newOwner": "¿Seguro que quieres abandonar {{teamName}}? El nuevo propietario será {{newOwner}}",
"actionButtons.leaveTeam.confirm": "Abandonar",

View File

@@ -2,6 +2,8 @@
"submit": "Finalizar",
"labels.name": "Nombre",
"labels.bio": "Biografía",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Creando nuevo equipo",
"editTeam.header": "",
"teamSearch.placeholder": "Buscar un equipo o jugador...",
"actionButtons.leaveTeam": "Abandonar equipo",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Abandonar",

View File

@@ -2,6 +2,8 @@
"submit": "Envoyer",
"labels.name": "Nom",
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Création d'une nouvelle équipe",
"editTeam.header": "",
"teamSearch.placeholder": "Rechercher une équipe ou un joueur...",
"actionButtons.leaveTeam": "Quitter l'équipe",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Quitter",

View File

@@ -2,6 +2,8 @@
"submit": "Envoyer",
"labels.name": "Nom",
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "Team Bluesky",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Création d'une nouvelle équipe",
"editTeam.header": "",
"teamSearch.placeholder": "Rechercher une équipe ou un joueur...",
"actionButtons.leaveTeam": "Quitter l'équipe",
"actionButtons.makeMainTeam": "Créer l'équipe principale",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "Êtes vous sur de vouloir quitté {{teamName}}?",
"leaveTeam.header.newOwner": "Êtes vous sur de vouloir quitté {{teamName}}? Le nouveau capitaine sera {{newOwner}}",
"actionButtons.leaveTeam.confirm": "Quitter",

View File

@@ -2,6 +2,8 @@
"submit": "שליחה",
"labels.name": "שם",
"labels.bio": "ביו",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "יצירת צוות חדש",
"editTeam.header": "",
"teamSearch.placeholder": "חיפוש צוות או שחקן...",
"actionButtons.leaveTeam": "לעזוב צוות",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "לעזוב",

View File

@@ -2,6 +2,8 @@
"submit": "Invia",
"labels.name": "Nome",
"labels.bio": "Biografia",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky del team",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Crea una nuova team",
"editTeam.header": "",
"teamSearch.placeholder": "Cerca un team o un giocatore",
"actionButtons.leaveTeam": "Lascia team",
"actionButtons.makeMainTeam": "Rendi team principale",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "Sei sicuro/a di voler lasciare {{teamName}}?",
"leaveTeam.header.newOwner": "Sei sicur/o di voler lasciare {{teamName}}? Il nuovo proprietario sarà {{newOwner}}",
"actionButtons.leaveTeam.confirm": "Lascia",

View File

@@ -2,6 +2,8 @@
"submit": "送信",
"labels.name": "名前",
"labels.bio": "自己紹介",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "チームの Bluesky",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "新しいチームを作成する",
"editTeam.header": "",
"teamSearch.placeholder": "チーム・プレイヤーを検索...",
"actionButtons.leaveTeam": "チームを抜ける",
"actionButtons.makeMainTeam": "新しいメインチームを作る",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "チームを抜ける",

View File

@@ -2,6 +2,8 @@
"submit": "제출",
"labels.name": "이름",
"labels.bio": "소개",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "",
"editTeam.header": "",
"teamSearch.placeholder": "",
"actionButtons.leaveTeam": "",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "",

View File

@@ -2,6 +2,8 @@
"submit": "Verzenden",
"labels.name": "Naam",
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "",
"editTeam.header": "",
"teamSearch.placeholder": "",
"actionButtons.leaveTeam": "",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "",

View File

@@ -2,6 +2,8 @@
"submit": "Złóż",
"labels.name": "Imię",
"labels.bio": "Opis",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Uwtorzenie nowej drużyny",
"editTeam.header": "",
"teamSearch.placeholder": "Wyszukaj drużynę lub gracza...",
"actionButtons.leaveTeam": "Opuść drużynę",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Opuść",

View File

@@ -2,6 +2,8 @@
"submit": "Enviar",
"labels.name": "Nome",
"labels.bio": "Bio",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Criando um novo time",
"editTeam.header": "",
"teamSearch.placeholder": "Pesquisar por um time ou jogador...",
"actionButtons.leaveTeam": "Sair do Time",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "Sair",

View File

@@ -2,6 +2,8 @@
"submit": "Опубликовать",
"labels.name": "Название",
"labels.bio": "Описание",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "Bluesky команды",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "Создать новую команду",
"editTeam.header": "",
"teamSearch.placeholder": "Поиск игрока или команды...",
"actionButtons.leaveTeam": "Покинуть команду",
"actionButtons.makeMainTeam": "Сделать главной командой",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "Вы точно хотите покинуть {{teamName}}?",
"leaveTeam.header.newOwner": "Вы точно хотите покинуть {{teamName}}? Новым владельцем будет {{newOwner}}",
"actionButtons.leaveTeam.confirm": "Покинуть",

View File

@@ -2,6 +2,8 @@
"submit": "确认",
"labels.name": "名称",
"labels.bio": "简介",
"labels.logo": "",
"labels.banner": "",
"labels.tag": "",
"labels.teamBsky": "",
"labels.clockFormat": "",

View File

@@ -1,8 +1,11 @@
{
"newTeam.header": "创建一支新队伍",
"editTeam.header": "",
"teamSearch.placeholder": "搜索队伍或玩家...",
"actionButtons.leaveTeam": "退出队伍",
"actionButtons.makeMainTeam": "",
"actionButtons.mainTeam": "",
"actionButtons.teamActions": "",
"leaveTeam.header": "",
"leaveTeam.header.newOwner": "",
"actionButtons.leaveTeam.confirm": "退出",