mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 04:05:54 -05:00
Convert edit teams form to SendouForm (#3138)
This commit is contained in:
@@ -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" },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
77
app/features/img-upload/image-field.server.ts
Normal file
77
app/features/img-upload/image-field.server.ts
Normal 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"
|
||||
);
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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" }),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
app/form/fields/ImageFormField.module.css
Normal file
13
app/form/fields/ImageFormField.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
96
app/form/fields/ImageFormField.tsx
Normal file
96
app/form/fields/ImageFormField.tsx
Normal 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
60
app/form/image-field.ts
Normal 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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Fremlæg",
|
||||
"labels.name": "Navn",
|
||||
"labels.bio": "Biografi",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Senden",
|
||||
"labels.name": "Name",
|
||||
"labels.bio": "Über mich",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Finalizar",
|
||||
"labels.name": "Nombre",
|
||||
"labels.bio": "Biografía",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Envoyer",
|
||||
"labels.name": "Nom",
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Envoyer",
|
||||
"labels.name": "Nom",
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "Team Bluesky",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "שליחה",
|
||||
"labels.name": "שם",
|
||||
"labels.bio": "ביו",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "לעזוב",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "送信",
|
||||
"labels.name": "名前",
|
||||
"labels.bio": "自己紹介",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "チームの Bluesky",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "チームを抜ける",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "제출",
|
||||
"labels.name": "이름",
|
||||
"labels.bio": "소개",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Verzenden",
|
||||
"labels.name": "Naam",
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Złóż",
|
||||
"labels.name": "Imię",
|
||||
"labels.bio": "Opis",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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ść",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Enviar",
|
||||
"labels.name": "Nome",
|
||||
"labels.bio": "Bio",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "Опубликовать",
|
||||
"labels.name": "Название",
|
||||
"labels.bio": "Описание",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "Bluesky команды",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "Покинуть",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"submit": "确认",
|
||||
"labels.name": "名称",
|
||||
"labels.bio": "简介",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
"labels.clockFormat": "",
|
||||
|
||||
@@ -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": "退出",
|
||||
|
||||
Reference in New Issue
Block a user