diff --git a/app/db/tables.ts b/app/db/tables.ts index 2cf6d6bb4..b3714876a 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -4,7 +4,6 @@ import type { Insertable, JSONColumnType, Selectable, - Updateable, } from "kysely"; import type { AssociationVisibility } from "~/features/associations/associations-types"; import type { tags } from "~/features/calendar/calendar-constants"; @@ -34,6 +33,8 @@ type Generated = export type MemberRole = (typeof TEAM_MEMBER_ROLES)[number]; +export type MemberRoleType = "PLAYER" | "OTHER"; + /** In SQLite booleans are presented as 0 (false) and 1 (true) */ export type DBBoolean = number; @@ -98,6 +99,9 @@ export interface TeamMember { isManager: Generated; leftAt: number | null; role: MemberRole | null; + customRole: string | null; + /** If customRole is defined, this classifies how the role should be treated */ + roleType: MemberRoleType | null; teamId: number; userId: number; isMainTeam: DBBoolean; @@ -1439,7 +1443,6 @@ export interface SplatoonRotation { export type Tables = { [P in keyof DB]: Selectable }; export type TablesInsertable = { [P in keyof DB]: Insertable }; -export type TablesUpdatable = { [P in keyof DB]: Updateable }; export interface DB { AllTeam: Team; diff --git a/app/features/scrims/actions/scrims.new.server.ts b/app/features/scrims/actions/scrims.new.server.ts index 73b526f87..973f8eb7f 100644 --- a/app/features/scrims/actions/scrims.new.server.ts +++ b/app/features/scrims/actions/scrims.new.server.ts @@ -12,7 +12,7 @@ import { assertUnreachable } from "~/utils/types"; import { scrimsPage } from "~/utils/urls"; import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server"; import * as TeamRepository from "../../team/TeamRepository.server"; -import { NON_PLAYER_TEAM_ROLES } from "../../team/team-constants"; +import { getMemberRoleType } from "../../team/team-utils"; import * as ScrimPostRepository from "../ScrimPostRepository.server"; import { LUTI_DIVS, SCRIM } from "../scrims-constants"; import { @@ -120,7 +120,7 @@ export const usersListForPost = async ({ errorToastIfFalsy(team, "User is not a member of this team"); const filteredMembers = team.members.filter( - (member) => !member.role || !NON_PLAYER_TEAM_ROLES.includes(member.role), + (member) => getMemberRoleType(member) !== "OTHER", ); // handle case when all users are from excluded roles diff --git a/app/features/team/TeamMemberRepository.server.ts b/app/features/team/TeamMemberRepository.server.ts deleted file mode 100644 index db03d403a..000000000 --- a/app/features/team/TeamMemberRepository.server.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { db } from "~/db/sql"; -import type { TablesUpdatable } from "~/db/tables"; - -export function update( - where: { teamId: number; userId: number }, - values: TablesUpdatable["TeamMember"], -) { - return db - .updateTable("AllTeamMember") - .set(values) - .where("AllTeamMember.teamId", "=", where.teamId) - .where("AllTeamMember.userId", "=", where.userId) - .execute(); -} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index ad7be9200..f8634056a 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -64,13 +64,19 @@ export function searchByName({ .select(["User.id", "User.username"]) .whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id") .where((eb2) => - eb2.or([ - eb2("TeamMemberWithSecondary.role", "is", null), - eb2( - "TeamMemberWithSecondary.role", - "not in", - NON_PLAYER_TEAM_ROLES, - ), + eb2.and([ + eb2.or([ + eb2("TeamMemberWithSecondary.role", "is", null), + eb2( + "TeamMemberWithSecondary.role", + "not in", + NON_PLAYER_TEAM_ROLES, + ), + ]), + eb2.or([ + eb2("TeamMemberWithSecondary.roleType", "is", null), + eb2("TeamMemberWithSecondary.roleType", "!=", "OTHER"), + ]), ]), ) .orderBy("TeamMemberWithSecondary.isOwner", "desc"), @@ -171,6 +177,8 @@ export function findByCustomUrl( .select(({ eb: innerEb }) => [ ...COMMON_USER_FIELDS, "TeamMemberWithSecondary.role", + "TeamMemberWithSecondary.customRole", + "TeamMemberWithSecondary.roleType", "TeamMemberWithSecondary.isOwner", "TeamMemberWithSecondary.isManager", "TeamMemberWithSecondary.isMainTeam", @@ -312,7 +320,7 @@ export async function teamsByMemberUserId( eb .selectFrom("TeamMemberWithSecondary as m2") .innerJoin("User", "User.id", "m2.userId") - .select([...COMMON_USER_FIELDS, "m2.role"]) + .select([...COMMON_USER_FIELDS, "m2.role", "m2.roleType"]) .whereRef("TeamMemberWithSecondary.teamId", "=", "m2.teamId"), ).as("members"), ]) @@ -545,51 +553,102 @@ export function handleMemberLeaving({ userId: number; teamId: number; newOwnerUserId?: number; +}) { + return db + .transaction() + .execute((trx) => memberLeave(trx, { userId, teamId, newOwnerUserId })); +} + +/** + * Applies a roster edit in a single transaction: updates each kept member's role + * & editor status and removes (kicks) the members in `kickedUserIds`. + */ +export function updateRoster({ + teamId, + members, + kickedUserIds, +}: { + teamId: number; + members: Array<{ + userId: number; + role: Tables["TeamMember"]["role"]; + customRole: Tables["TeamMember"]["customRole"]; + roleType: Tables["TeamMember"]["roleType"]; + isManager: boolean; + }>; + kickedUserIds: number[]; }) { return db.transaction().execute(async (trx) => { - const currentTeams = await teamsByMemberUserId(userId, trx); - - const teamToLeave = currentTeams.find((team) => team.id === teamId); - invariant(teamToLeave, "User is not a member of this team"); - invariant( - !teamToLeave.isOwner || newOwnerUserId, - "New owner id must be provided when old is leaving", - ); - - const wasMainTeam = teamToLeave.isMainTeam; - const newMainTeam = currentTeams.find((team) => team.id !== teamId); - if (wasMainTeam && newMainTeam) { - await trx - .updateTable("AllTeamMember") - .set({ - isMainTeam: 1, - }) - .where("userId", "=", userId) - .where("teamId", "=", newMainTeam.id) - .execute(); + for (const userId of kickedUserIds) { + await memberLeave(trx, { userId, teamId }); } - await trx - .updateTable("AllTeamMember") - .set({ - leftAt: databaseTimestampNow(), - isMainTeam: 0, - isOwner: 0, - isManager: 0, - }) - .where("userId", "=", userId) - .where("teamId", "=", teamId) - .execute(); - if (newOwnerUserId) { + for (const member of members) { await trx .updateTable("AllTeamMember") .set({ - isOwner: 1, - isManager: 0, + role: member.role, + customRole: member.customRole, + roleType: member.roleType, + isManager: member.isManager ? 1 : 0, }) - .where("userId", "=", newOwnerUserId) .where("teamId", "=", teamId) + .where("userId", "=", member.userId) .execute(); } }); } + +async function memberLeave( + trx: Transaction, + { + userId, + teamId, + newOwnerUserId, + }: { userId: number; teamId: number; newOwnerUserId?: number }, +) { + const currentTeams = await teamsByMemberUserId(userId, trx); + + const teamToLeave = currentTeams.find((team) => team.id === teamId); + invariant(teamToLeave, "User is not a member of this team"); + invariant( + !teamToLeave.isOwner || newOwnerUserId, + "New owner id must be provided when old is leaving", + ); + + const wasMainTeam = teamToLeave.isMainTeam; + const newMainTeam = currentTeams.find((team) => team.id !== teamId); + if (wasMainTeam && newMainTeam) { + await trx + .updateTable("AllTeamMember") + .set({ + isMainTeam: 1, + }) + .where("userId", "=", userId) + .where("teamId", "=", newMainTeam.id) + .execute(); + } + + await trx + .updateTable("AllTeamMember") + .set({ + leftAt: databaseTimestampNow(), + isMainTeam: 0, + isOwner: 0, + isManager: 0, + }) + .where("userId", "=", userId) + .where("teamId", "=", teamId) + .execute(); + if (newOwnerUserId) { + await trx + .updateTable("AllTeamMember") + .set({ + isOwner: 1, + isManager: 0, + }) + .where("userId", "=", newOwnerUserId) + .where("teamId", "=", teamId) + .execute(); + } +} diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts index 73f3e843c..709651c3a 100644 --- a/app/features/team/actions/t.$customUrl.roster.server.ts +++ b/app/features/team/actions/t.$customUrl.roster.server.ts @@ -1,13 +1,13 @@ import type { ActionFunction } from "react-router"; +import { redirect } from "react-router"; +import type { MemberRole, MemberRoleType } from "~/db/tables"; import { requireUser } from "~/features/auth/core/user.server"; -import { - errorToastIfFalsy, - notFoundIfFalsy, - parseRequestPayload, -} from "~/utils/remix.server"; +import { parseFormData } from "~/form/parse.server"; +import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import * as TeamMemberRepository from "../TeamMemberRepository.server"; +import { teamPage } from "~/utils/urls"; import * as TeamRepository from "../TeamRepository.server"; +import { CUSTOM_ROLE_VALUE } from "../team-schemas"; import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server"; import { isTeamManager } from "../team-utils"; @@ -21,66 +21,69 @@ export const action: ActionFunction = async ({ request, params }) => { "Only team manager or owner can manage roster", ); - const data = await parseRequestPayload({ + const result = await parseFormData({ request, schema: manageRosterSchema, }); + if (!result.success) { + return { fieldErrors: result.fieldErrors }; + } + const data = result.data; switch (data._action) { - case "DELETE_MEMBER": { - const member = team.members.find((m) => m.id === data.userId); - - errorToastIfFalsy(member, "Member not found"); - errorToastIfFalsy(member.id !== user.id, "Can't delete yourself"); - errorToastIfFalsy(!member.isOwner, "Can't delete owner"); - - await TeamRepository.handleMemberLeaving({ - teamId: team.id, - userId: data.userId, - }); - break; - } case "RESET_INVITE_LINK": { await TeamRepository.resetInviteCode(team.id); break; } - case "ADD_MANAGER": { - await TeamMemberRepository.update( - { teamId: team.id, userId: data.userId }, - { - isManager: 1, - }, + case "UPDATE_ROSTER": { + const submittedIds = new Set(data.members.map((m) => m.userId)); + const existingMembersById = new Map( + team.members.map((member) => [member.id, member]), ); - break; - } - case "REMOVE_MANAGER": { - const member = team.members.find((m) => m.id === data.userId); - errorToastIfFalsy(member, "Member not found"); - errorToastIfFalsy( - member.id !== user.id, - "Can't remove yourself as manager", - ); + for (const member of data.members) { + errorToastIfFalsy( + existingMembersById.has(member.userId), + "Member not found", + ); + } - await TeamMemberRepository.update( - { teamId: team.id, userId: data.userId }, - { - isManager: 0, - }, - ); + const kickedUserIds = team.members + .filter((member) => !submittedIds.has(member.id)) + .map((member) => { + errorToastIfFalsy(!member.isOwner, "Can't kick the owner"); + errorToastIfFalsy(member.id !== user.id, "Can't kick yourself"); + return member.id; + }); - break; - } - case "UPDATE_MEMBER_ROLE": { - await TeamMemberRepository.update( - { teamId: team.id, userId: data.userId }, - { - role: data.role || null, - }, - ); + await TeamRepository.updateRoster({ + teamId: team.id, + members: data.members.map((member) => { + const isCustom = member.role === CUSTOM_ROLE_VALUE; + const existing = existingMembersById.get(member.userId); + const isProtectedMember = Boolean( + existing?.isOwner || existing?.id === user.id, + ); - break; + return { + userId: member.userId, + role: isCustom + ? null + : ((member.role || null) as MemberRole | null), + customRole: isCustom ? member.customRole || null : null, + roleType: isCustom + ? ((member.roleType || null) as MemberRoleType | null) + : null, + isManager: isProtectedMember + ? Boolean(existing?.isManager) + : member.isManager, + }; + }), + kickedUserIds, + }); + + return redirect(teamPage(customUrl)); } default: { assertUnreachable(data); diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx index eb36a47a4..0b6cac377 100644 --- a/app/features/team/routes/t.$customUrl.index.tsx +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -1,4 +1,13 @@ -import { LogOut, Menu, SquarePen, Star, Trash2, Users } from "lucide-react"; +import { + LogOut, + Menu, + SquarePen, + Star, + Swords, + Trash2, + Users, + Wrench, +} from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; import { Link, useFetcher, useMatches } from "react-router"; @@ -6,6 +15,12 @@ import { Avatar } from "~/components/Avatar"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; import { SendouMenu, SendouMenuItem } from "~/components/elements/Menu"; +import { + SendouTab, + SendouTabList, + SendouTabPanel, + SendouTabs, +} from "~/components/elements/Tabs"; import { WeaponImage } from "~/components/Image"; import { Placement } from "~/components/Placement"; import { useUser } from "~/features/auth/core/user"; @@ -17,6 +32,7 @@ import { action } from "../actions/t.$customUrl.index.server"; import type * as TeamRepository from "../TeamRepository.server"; import styles from "../team.module.css"; import { + getMemberRoleType, isTeamManager, isTeamMember, isTeamOwner, @@ -26,10 +42,27 @@ import { export { action }; export default function TeamIndexPage() { + const { t } = useTranslation(["team"]); const [, parentRoute] = useMatches(); invariant(parentRoute); const layoutData = parentRoute.data as TeamLoaderData; + const members = layoutData.team.members; + const playerMembers = members.filter( + (member) => getMemberRoleType(member) !== "OTHER", + ); + const otherMembers = members.filter( + (member) => getMemberRoleType(member) === "OTHER", + ); + const showSections = playerMembers.length > 0 && otherMembers.length > 0; + + const renderMember = (member: (typeof members)[number]) => ( + + + + + ); + return (
@@ -39,14 +72,34 @@ export default function TeamIndexPage() { {layoutData.team.bio ? (
{layoutData.team.bio}
) : null} -
- {layoutData.team.members.map((member, i) => ( - - - - - ))} -
+ {showSections ? ( + + + } + number={playerMembers.length} + > + {t("team:roster.sections.players")} + + } + number={otherMembers.length} + > + {t("team:roster.sections.other")} + + + +
{playerMembers.map(renderMember)}
+
+ +
{otherMembers.map(renderMember)}
+
+
+ ) : ( +
{members.map(renderMember)}
+ )}
); } @@ -274,7 +327,14 @@ function MemberRow({ className={styles.member} data-testid={member.isOwner ? `member-owner-${member.id}` : undefined} > - {member.role ? ( + {member.customRole ? ( + + {member.customRole} + + ) : member.role ? ( ) : null} - {member.role ? ( + {member.customRole ? ( + {member.customRole} + ) : member.role ? ( {t(`team:roles.${member.role}`)} diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 5f94a708d..cd252ad99 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -1,26 +1,23 @@ -import clsx from "clsx"; -import { Check, Clipboard, Trash } from "lucide-react"; -import * as React from "react"; +import { Check, Clipboard } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; -import { Form, useFetcher, useLoaderData } from "react-router"; +import { Form, useLoaderData } from "react-router"; import { Alert } from "~/components/Alert"; +import { Avatar } from "~/components/Avatar"; import { SendouButton } from "~/components/elements/Button"; import { SendouPopover } from "~/components/elements/Popover"; -import { SendouSwitch } from "~/components/elements/Switch"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Main } 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 { ArrayItemRenderContext } from "~/form/types"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { metaTags } from "~/utils/remix"; import { joinTeamPage } from "~/utils/urls"; import { action } from "../actions/t.$customUrl.roster.server"; import { loader } from "../loaders/t.$customUrl.roster.server"; -import type * as TeamRepository from "../TeamRepository.server"; -import styles from "../team.module.css"; -import { TEAM_MEMBER_ROLES } from "../team-constants"; +import { CUSTOM_ROLE_VALUE, updateRosterSchema } from "../team-schemas"; import { isTeamFull } from "../team-utils"; export { action, loader }; @@ -36,7 +33,7 @@ export default function ManageTeamRosterPage() { const { t } = useTranslation(["team"]); return ( -
+
@@ -105,129 +102,72 @@ function InviteCodeSection() { } function MemberActions() { - const { t } = useTranslation(["team"]); + const { t } = useTranslation(["common", "team"]); const { team } = useLoaderData(); + const user = useUser(); + + const isProtectedMember = (member: { id: number; isOwner: number }) => + Boolean(member.isOwner) || member.id === user!.id; return (

{t("team:roster.members.header")}

-
- {team.members.map((member, i) => ( - - ))} -
+ ({ + userId: member.id, + role: member.customRole ? CUSTOM_ROLE_VALUE : (member.role ?? null), + customRole: member.customRole ?? null, + roleType: member.roleType ?? null, + isManager: Boolean(member.isManager), + })), + }} + > + {({ FormField }) => ( + { + const member = team.members.find( + (m) => m.id === (item as { userId: number }).userId, + ); + return Boolean(member) && !isProtectedMember(member!); + }} + > + {({ index, itemName, values }: ArrayItemRenderContext) => { + const member = team.members.find( + (m) => m.id === (values as { userId: number }).userId, + ); + + return ( +
+
+ {member ? : null} + {member?.username} +
+ + {(values as { role: string | null }).role === + CUSTOM_ROLE_VALUE ? ( + <> + + + + ) : null} + {member && !isProtectedMember(member) ? ( + + ) : null} +
+ ); + }} +
+ )} +
); } - -const NO_ROLE = "NO_ROLE"; -function MemberRow({ - member, - number, -}: { - member: TeamRepository.findByCustomUrl["members"][number]; - number: number; -}) { - const { team } = useLoaderData(); - const { t } = useTranslation(["team"]); - const user = useUser(); - - const roleFetcher = useFetcher(); - const editorFetcher = useFetcher(); - - const isSelf = user!.id === member.id; - const role = team.members.find((m) => m.id === member.id)?.role ?? NO_ROLE; - - const isThisMemberOwner = Boolean( - team.members.find((m) => m.id === member.id)?.isOwner, - ); - const isThisMemberManager = Boolean( - team.members.find((m) => m.id === member.id)?.isManager, - ); - - const editorIsBeingAdded = - editorFetcher.formData?.get("_action") === "ADD_MANAGER"; - const editorIsBeingRemoved = - editorFetcher.formData?.get("_action") === "REMOVE_MANAGER"; - - return ( - -
- {member.username} -
-
- -
-
- - editorFetcher.submit( - { - _action: isSelected ? "ADD_MANAGER" : "REMOVE_MANAGER", - userId: String(member.id), - }, - { method: "post" }, - ) - } - isSelected={ - editorIsBeingAdded - ? true - : editorIsBeingRemoved - ? false - : isThisMemberManager - } - data-testid="editor-switch" - > - {t("team:editor.label")} - -
-
- - } - data-testid={!isSelf ? "kick-button" : undefined} - > - {t("team:actionButtons.kick")} - - -
-
-
- ); -} diff --git a/app/features/team/team-constants.ts b/app/features/team/team-constants.ts index 786d0f873..8ddf8bd1d 100644 --- a/app/features/team/team-constants.ts +++ b/app/features/team/team-constants.ts @@ -27,3 +27,5 @@ export const TEAM_MEMBER_ROLES = [ /** Roles that are not part of a team's active competitive lineup. Excluded when sourcing a roster (e.g. prefilling tournament registration or a scrim post). */ export const NON_PLAYER_TEAM_ROLES: readonly (typeof TEAM_MEMBER_ROLES)[number][] = ["CHEERLEADER", "COACH", "SUB"]; + +export const CUSTOM_ROLE_MAX_LENGTH = 32; diff --git a/app/features/team/team-schemas.server.ts b/app/features/team/team-schemas.server.ts index 26f7a07d4..2eac66dc5 100644 --- a/app/features/team/team-schemas.server.ts +++ b/app/features/team/team-schemas.server.ts @@ -1,9 +1,12 @@ import { z } from "zod"; import { mySlugify } from "~/utils/urls"; -import { _action, id, themeInputSchema } from "~/utils/zod"; +import { _action, themeInputSchema } from "~/utils/zod"; import * as TeamRepository from "./TeamRepository.server"; -import { TEAM_MEMBER_ROLES } from "./team-constants"; -import { createTeamSchema, editTeamFormSchema } from "./team-schemas"; +import { + createTeamSchema, + editTeamFormSchema, + updateRosterSchema, +} from "./team-schemas"; export const createTeamSchemaServer = z.object({ ...createTeamSchema.shape, @@ -47,24 +50,8 @@ export const editTeamActionSchema = z.union([ ]); export const manageRosterSchema = z.union([ + updateRosterSchema, z.object({ _action: _action("RESET_INVITE_LINK"), }), - z.object({ - _action: _action("DELETE_MEMBER"), - userId: id, - }), - z.object({ - _action: _action("ADD_MANAGER"), - userId: id, - }), - z.object({ - _action: _action("REMOVE_MANAGER"), - userId: id, - }), - z.object({ - _action: _action("UPDATE_MEMBER_ROLE"), - userId: id, - role: z.union([z.enum(TEAM_MEMBER_ROLES), z.literal("")]), - }), ]); diff --git a/app/features/team/team-schemas.ts b/app/features/team/team-schemas.ts index 8259aca15..23f0301cd 100644 --- a/app/features/team/team-schemas.ts +++ b/app/features/team/team-schemas.ts @@ -1,13 +1,22 @@ import { z } from "zod"; import { + array, + fieldset, + idConstant, image, + selectOptional, stringConstant, textAreaOptional, textFieldOptional, textFieldRequired, + toggle, } from "~/form/fields"; import { mySlugify } from "~/utils/urls"; -import { TEAM } from "./team-constants"; +import { + CUSTOM_ROLE_MAX_LENGTH, + TEAM, + TEAM_MEMBER_ROLES, +} from "./team-constants"; const teamNameValidate = { func: (teamName: string) => @@ -50,3 +59,74 @@ export const editTeamFormSchema = z.object({ logo: image({ label: "labels.logo" }), banner: image({ label: "labels.banner", dimensions: "thick-banner" }), }); + +/** Sentinel `role` value selected to switch a member to a free-text custom role. Never stored. */ +export const CUSTOM_ROLE_VALUE = "CUSTOM"; + +export const updateRosterSchema = z + .object({ + _action: stringConstant("UPDATE_ROSTER"), + members: array({ + max: TEAM.MAX_MEMBER_COUNT, + addable: false, + field: fieldset({ + fields: z.object({ + userId: idConstant(), + role: selectOptional({ + label: "labels.teamMemberRole", + items: [ + ...TEAM_MEMBER_ROLES.map((role) => ({ + value: role, + label: `options.teamMemberRole.${role}` as const, + })), + { + value: CUSTOM_ROLE_VALUE, + label: "options.teamMemberRole.CUSTOM" as const, + }, + ], + }), + customRole: textFieldOptional({ + label: "labels.teamMemberCustomRole", + maxLength: CUSTOM_ROLE_MAX_LENGTH, + }), + roleType: selectOptional({ + label: "labels.teamMemberRoleType", + items: [ + { value: "PLAYER", label: "options.teamMemberRoleType.PLAYER" }, + { value: "OTHER", label: "options.teamMemberRoleType.OTHER" }, + ], + }), + isManager: toggle({ label: "labels.teamEditor" }), + }), + }), + }), + }) + .superRefine((data, ctx) => { + for (const [index, member] of data.members.entries()) { + const isCustom = member.role === CUSTOM_ROLE_VALUE; + + if (isCustom && !member.customRole) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["members", index, "customRole"], + message: "forms:errors.customRoleRequired", + }); + } + + if (isCustom && !member.roleType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["members", index, "roleType"], + message: "forms:errors.customRoleTypeRequired", + }); + } + + if (!isCustom && member.customRole) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["members", index, "customRole"], + message: "forms:errors.customRoleOnlyWhenCustom", + }); + } + } + }); diff --git a/app/features/team/team-utils.ts b/app/features/team/team-utils.ts index 3623a23c3..054ebe90f 100644 --- a/app/features/team/team-utils.ts +++ b/app/features/team/team-utils.ts @@ -1,6 +1,6 @@ -import type { Tables } from "~/db/tables"; +import type { MemberRole, MemberRoleType, Tables } from "~/db/tables"; import type * as TeamRepository from "./TeamRepository.server"; -import { TEAM } from "./team-constants"; +import { NON_PLAYER_TEAM_ROLES, TEAM } from "./team-constants"; export function isTeamOwner({ team, @@ -74,6 +74,20 @@ export function resolveNewOwner( return null; } +/** + * Resolves how a team member's role should be classified. For custom roles the explicit + * `roleType` is authoritative; for predefined roles it is derived from {@link NON_PLAYER_TEAM_ROLES}. + * Returns `null` when the member has no role at all (treated as a player by callers). + */ +export function getMemberRoleType(member: { + role: MemberRole | null; + roleType: MemberRoleType | null; +}): MemberRoleType | null { + if (member.roleType) return member.roleType; + if (!member.role) return null; + return NON_PLAYER_TEAM_ROLES.includes(member.role) ? "OTHER" : "PLAYER"; +} + /** * Returns a list of participant IDs who are considered "substitutes" for a given tournament result, * based on the team's member history and the result's participants. diff --git a/app/features/team/team.module.css b/app/features/team/team.module.css index d097bd624..ae3d750a4 100644 --- a/app/features/team/team.module.css +++ b/app/features/team/team.module.css @@ -228,25 +228,6 @@ margin-block-start: var(--s-2); } -.rosterMembers { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--s-4); - place-items: center; - max-width: max-content; -} - -.rosterMember { - justify-self: flex-start; - font-weight: var(--weight-bold); - font-size: var(--font-sm); -} - -.rosterSeparator { - grid-column: 1 / 3; - width: 100%; -} - .inviteContainer { margin-block-start: var(--s-16); display: flex; @@ -298,14 +279,6 @@ display: none; } - .rosterMembers { - grid-template-columns: 1fr 1fr max-content max-content; - } - - .rosterSeparator { - display: none; - } - .bannerPlaceholder { height: 12rem; } diff --git a/app/features/tournament-admin/routes/to.$id.admin._index.tsx b/app/features/tournament-admin/routes/to.$id.admin._index.tsx index addf81d77..3aef7fd45 100644 --- a/app/features/tournament-admin/routes/to.$id.admin._index.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin._index.tsx @@ -155,6 +155,7 @@ function TeamRow({
diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index be77109c5..bc2f3c31e 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -198,6 +198,7 @@ export async function findProfileByIdentifier( "Team.id", "TeamMemberWithSecondary.isMainTeam", "TeamMemberWithSecondary.role as userTeamRole", + "TeamMemberWithSecondary.customRole as userTeamCustomRole", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "avatarUrl", ), diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 58ae72e5e..9c4dcd39e 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -249,7 +249,11 @@ function TeamInfo() { ) : null}
{data.user.team.name} - {data.user.team.userTeamRole ? ( + {data.user.team.userTeamCustomRole ? ( +
+ {data.user.team.userTeamCustomRole} +
+ ) : data.user.team.userTeamRole ? (
{t(`team:roles.${data.user.team.userTeamRole}`)}
@@ -310,7 +314,11 @@ function SecondaryTeamsPopover() { ) : null} {team.name} - {team.userTeamRole ? ( + {team.userTeamCustomRole ? ( +
+ {team.userTeamCustomRole} +
+ ) : team.userTeamRole ? (
{t(`team:roles.${team.userTeamRole}`)}
diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index 33acb7f76..1af18e6a9 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -61,6 +61,8 @@ interface FormFieldProps { | ((props: ArrayItemRenderContext) => React.ReactNode); /** Field-specific options */ options?: unknown; + /** For `array` fields: hide the remove button for items where this returns false. */ + canRemoveItem?: (itemValue: unknown, index: number) => boolean; } export function FormField({ @@ -71,6 +73,7 @@ export function FormField({ field, children, options, + canRemoveItem, }: FormFieldProps) { const context = useOptionalFormFieldContext(); @@ -379,6 +382,7 @@ export function FormField({ onChange={handleChange as (v: unknown[]) => void} isObjectArray={isObjectArray} itemInitialValue={itemInitialValue} + canRemoveItem={canRemoveItem} renderItem={(idx, itemName) => { if (hasCustomRender && isObjectArray) { const arrayValue = value as Record[]; diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index a552b4c62..27799e1f7 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -16,6 +16,8 @@ type ArrayFormFieldProps = Omit, "field"> & { isObjectArray?: boolean; sortable?: boolean; itemInitialValue?: unknown; + addable?: boolean; + canRemoveItem?: (itemValue: unknown, index: number) => boolean; }; export function ArrayFormField({ @@ -31,6 +33,8 @@ export function ArrayFormField({ isObjectArray, sortable, itemInitialValue, + addable = true, + canRemoveItem, }: ArrayFormFieldProps) { const { t } = useTranslation(["common"]); const { translatedLabel, translatedBottomText, translatedError } = @@ -89,7 +93,9 @@ export function ArrayFormField({ // so it shouldn't offer a remove button (you can't go below one visible row // anyway). A lone edited row stays removable so the only item can be cleared. const canRemoveAt = (index: number) => - count > min && (count > minVisible || !isPristineItem(value[index])); + (canRemoveItem ? canRemoveItem(value[index], index) : true) && + count > min && + (count > minVisible || !isPristineItem(value[index])); const handleRemoveAt = (index: number) => { const next = value.filter((_, i) => i !== index); @@ -147,16 +153,18 @@ export function ArrayFormField({ {translatedBottomText && !translatedError ? ( {translatedBottomText} ) : null} - } - onPress={handleAdd} - isDisabled={count >= max} - className="m-0-auto" - > - {t("common:actions.add")} - + {addable ? ( + } + onPress={handleAdd} + isDisabled={count >= max} + className="m-0-auto" + > + {t("common:actions.add")} + + ) : null}
); } diff --git a/app/form/types.ts b/app/form/types.ts index 429654c2f..44a632b12 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -129,6 +129,8 @@ export interface FormFieldArray min?: number; max: number; field: S; + /** When false, the "Add" button is hidden (the array can only be edited/shrunk, not grown). Defaults to true. */ + addable?: boolean; } interface FormFieldTimeRange extends FormFieldBase { @@ -269,6 +271,7 @@ export type TypedFormFieldProps< label?: string; disabled?: boolean; maxCount?: number; + canRemoveItem?: (itemValue: unknown, index: number) => boolean; children?: | ((props: FormFieldChildrenProps) => React.ReactNode) | ((props: ArrayItemRenderContext) => React.ReactNode); @@ -285,6 +288,7 @@ export type FlexibleFormFieldProps = { label?: string; disabled?: boolean; maxCount?: number; + canRemoveItem?: (itemValue: unknown, index: number) => boolean; children?: | ((props: FormFieldChildrenProps) => React.ReactNode) | ((props: ArrayItemRenderContext) => React.ReactNode); diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 182ca557d..5c971ecbf 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index 89c58eb60..cf290bf9c 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 3f03e4b77..d9f3ea5fc 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 60c14efbf..84e07bd7f 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index 688349df1..cc8dabc9b 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 1edc40e36..766d45d39 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 0622b8554..9b8a28b78 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index e81ecf56c..a6c85a340 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index f73745f4d..749abcb51 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index e5aa1c596..c3abad266 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 3a2aa434c..065414b87 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index e557955c1..3ec547cba 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 6d77c3b8a..c59a16250 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -73,18 +73,46 @@ test.describe("Team page", () => { await page.getByTestId("manage-roster-button").click(); - await page.getByTestId("role-select-0").selectOption("SUPPORT"); + await page + .getByTestId("member-row-0") + .locator("select") + .selectOption("SUPPORT"); - await page.getByTestId("member-row-3").isVisible(); - await page.getByTestId("kick-button").last().click(); - await modalClickConfirmButton(page); + await expect(page.getByTestId("member-row-3")).toBeVisible(); + await page + .locator("fieldset:has([data-testid='member-row-3'])") + .getByRole("button", { name: "Remove item" }) + .click(); await isNotVisible(page.getByTestId("member-row-3")); + await submit(page); + await navigate({ page, url: teamPage("alliance-rogue") }); await expect(page.getByTestId("member-row-role-0")).toHaveText("Support"); }); + test("sets a custom role for a member", async ({ page }) => { + await seed(page); + await impersonate(page, ADMIN_ID); + await navigate({ page, url: teamPage("alliance-rogue") }); + + await page.getByTestId("manage-roster-button").click(); + + const memberRow = page.getByTestId("member-row-1"); + await memberRow.locator("select").first().selectOption("CUSTOM"); + await memberRow.getByRole("textbox").fill("Strategist"); + await memberRow.locator("select").nth(1).selectOption("OTHER"); + + await submit(page); + + await navigate({ page, url: teamPage("alliance-rogue") }); + + // custom role is classified as "OTHER" so it lives under the "Other" tab + await page.getByRole("tab", { name: /Other/ }).click(); + await expect(page.getByText("Strategist").first()).toBeVisible(); + }); + test("deletes team", async ({ page }) => { await seed(page); await impersonate(page, ADMIN_ID); @@ -172,9 +200,8 @@ test.describe("Team page", () => { await page.getByTestId("manage-roster-button").click(); - await waitForPOSTResponse(page, async () => { - await page.getByLabel("Editor").first().click({ force: true }); - }); + await page.getByLabel("Editor").first().click({ force: true }); + await submit(page); await impersonate(page, NZAP_TEST_ID); await navigate({ page, url: editTeamPage("alliance-rogue") }); diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index a1634b6aa..ec8f0aa8a 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -303,11 +303,12 @@ test.describe("Tournament bracket", () => { url: tournamentAdminPage(tournamentId), }); - // check team 216 in to the underground bracket (after the start only - // checked in teams are listed so the row index is not stable) + // check team 216 in to the underground bracket — match on the stable team + // id rather than the seeded (faker-generated) team name or the row index, + // neither of which is stable after the start await adminTeamRowAction( page, - page.getByTestId("team-row").filter({ hasText: "Protective Wet Floor" }), + page.locator('[data-testid="team-row"][data-team-id="216"]'), "Check in (Underground bracket)", ); diff --git a/locales/da/forms.json b/locales/da/forms.json index 87ac80304..6ab897d82 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Kaptajn", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Frontlinje", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Støtte", + "options.teamMemberRole.MIDLINE": "Midlinje", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "Fleksibel", + "options.teamMemberRole.SUB": "Vikar", + "options.teamMemberRole.COACH": "Træner", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Holdnavnet er taget af et andet hold", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Våbenpulje", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/da/team.json b/locales/da/team.json index 8bc670d14..7daea4d56 100644 --- a/locales/da/team.json +++ b/locales/da/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Forlad hold", "actionButtons.editTeam": "Rediger hold", - "actionButtons.manageRoster": "Administrer hold", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Slet hold", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "Holdet kan ikke få flere medlemmer", "roster.inviteLink.header": "Del invitationslinket for at tilføje medlemmer", "roster.members.header": "Medlemmer", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Invitationskoden har ikke den korrekte længde. Har du kopieret hele URL-adressen? ", "validation.TEAM_FULL": "Holdet, som du prøver at blive medlem af, er fuldt", "validation.INVITE_CODE_WRONG": "Ukorrekt invitationskode", diff --git a/locales/de/forms.json b/locales/de/forms.json index 0adab18cb..f75a1610e 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Kapitän", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Support", + "options.teamMemberRole.MIDLINE": "Midline", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "Coach", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Es gibt bereits ein Team mit diesem Namen", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Waffenpool", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/de/team.json b/locales/de/team.json index 469b8b76d..6d4a3bfb9 100644 --- a/locales/de/team.json +++ b/locales/de/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Verlassen", "actionButtons.editTeam": "Team bearbeiten", - "actionButtons.manageRoster": "Aufstellung bearbeiten", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Team löschen", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "Team ist voll", "roster.inviteLink.header": "Teile den Einladungslink, um Mitglieder hinzuzufügen", "roster.members.header": "Mitglieder", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Einladungscode hat nicht die richtige Länge. Hast du die vollständige URL kopiert?", "validation.TEAM_FULL": "Das Team, dem du beitreten willst, ist bereits voll.", "validation.INVITE_CODE_WRONG": "Einladungscode ist falsch.", diff --git a/locales/en/forms.json b/locales/en/forms.json index a24809d3a..c41f1d950 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -6,6 +6,10 @@ "labels.banner": "Banner", "labels.tag": "Tag", "labels.teamBsky": "Team Bluesky", + "labels.teamEditor": "Editor", + "labels.teamMemberRole": "Role", + "labels.teamMemberCustomRole": "Custom role", + "labels.teamMemberRoleType": "Role type", "labels.clockFormat": "Clock format", "labels.disableBuildAbilitySorting": "Builds: Disable automatic ability sorting", "labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "Automatic", "options.clockFormat.24h": "24-hour", "options.clockFormat.12h": "12-hour", + "options.teamMemberRole.CAPTAIN": "Captain", + "options.teamMemberRole.CO_CAPTAIN": "Co-Captain", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "Slayer", + "options.teamMemberRole.SKIRMISHER": "Skirmisher", + "options.teamMemberRole.SUPPORT": "Support", + "options.teamMemberRole.MIDLINE": "Midline", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "Sub", + "options.teamMemberRole.COACH": "Coach", + "options.teamMemberRole.CHEERLEADER": "Cheerleader", + "options.teamMemberRole.CUSTOM": "Custom...", + "options.teamMemberRoleType.PLAYER": "Player", + "options.teamMemberRoleType.OTHER": "Other", "errors.required": "This field is required", "errors.minLength": "Must be at least {{min}} characters", "errors.maxLength": "Must be at most {{max}} characters", @@ -32,6 +51,9 @@ "errors.duplicateName": "There is already a team with this name", "errors.duplicateOrgName": "There is already an organization with this name", "errors.noOnlySpecialCharacters": "Name can't be only special characters", + "errors.customRoleRequired": "Enter a name for the custom role", + "errors.customRoleTypeRequired": "Select a role type for the custom role", + "errors.customRoleOnlyWhenCustom": "Custom role is only allowed when the role is set to Custom", "labels.weaponPool": "Weapon pool", "placeholders.weaponPoolFull": "Pool full - remove a weapon to add more", "labels.voiceChat": "Can voice chat", diff --git a/locales/en/team.json b/locales/en/team.json index 9a285f442..cf5b296e3 100644 --- a/locales/en/team.json +++ b/locales/en/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "Are you sure you want to leave {{teamName}}? New owner will be {{newOwner}}", "actionButtons.leaveTeam.confirm": "Leave", "actionButtons.editTeam": "Edit Team", - "actionButtons.manageRoster": "Manage Roster", + "actionButtons.manageRoster": "Manage Members", "actionButtons.deleteTeam": "Delete Team", "actionButtons.deleteTeam.profilePicture": "Remove Profile Picture", "actionButtons.deleteTeam.banner": "Remove Banner", @@ -49,6 +49,8 @@ "roster.teamFull": "Team is full", "roster.inviteLink.header": "Share invite link to add members", "roster.members.header": "Members", + "roster.sections.players": "Roster", + "roster.sections.other": "Other", "validation.SHORT_CODE": "Invite code is not the right length. Did you copy the full URL?", "validation.TEAM_FULL": "Team you are trying to join is full.", "validation.INVITE_CODE_WRONG": "Invite code is wrong.", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index b1aa0b37f..bca91c9c1 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "Etiqueta", "labels.teamBsky": "Bluesky del equipo", + "labels.teamEditor": "Editor", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "Formato de hora", "labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores", "labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "Automático", "options.clockFormat.24h": "24 horas", "options.clockFormat.12h": "12 horas", + "options.teamMemberRole.CAPTAIN": "Capitán", + "options.teamMemberRole.CO_CAPTAIN": "Co-Capitán", + "options.teamMemberRole.FRONTLINE": "Delantero", + "options.teamMemberRole.SLAYER": "Slayer", + "options.teamMemberRole.SKIRMISHER": "Skirmisher", + "options.teamMemberRole.SUPPORT": "Soporte", + "options.teamMemberRole.MIDLINE": "Centro", + "options.teamMemberRole.BACKLINE": "Defensor", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "Suplente", + "options.teamMemberRole.COACH": "Entrenador", + "options.teamMemberRole.CHEERLEADER": "Animador", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "Este campo es obligatorio", "errors.minLength": "Debe tener al menos {{min}} caracteres", "errors.maxLength": "Debe tener como máximo {{max}} caracteres", @@ -32,6 +51,9 @@ "errors.duplicateName": "Ya existe un equipo con ese nombre", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "El nombre no puede ser solo caracteres especiales", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Selección de armas", "placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más", "labels.voiceChat": "Puede usar chat de voz", diff --git a/locales/es-ES/team.json b/locales/es-ES/team.json index 69b69d35d..334908c03 100644 --- a/locales/es-ES/team.json +++ b/locales/es-ES/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "¿Seguro que quieres abandonar {{teamName}}? El nuevo propietario será {{newOwner}}", "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar Equipo", - "actionButtons.manageRoster": "Manejar Miembros", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Eliminar Equipo", "actionButtons.deleteTeam.profilePicture": "Eliminar foto de perfil", "actionButtons.deleteTeam.banner": "Eliminar banner", @@ -49,6 +49,8 @@ "roster.teamFull": "El equipo está lleno", "roster.inviteLink.header": "Comparte el link de invitación para agregar más miembros", "roster.members.header": "Miembros", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste la URL completa?", "validation.TEAM_FULL": "El equipo al que te intentas unir ya está lleno.", "validation.INVITE_CODE_WRONG": "El código de invitación es incorrecto.", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 575cf47c8..5a99a8487 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Capitán", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Delantero", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Soporte", + "options.teamMemberRole.MIDLINE": "Centro", + "options.teamMemberRole.BACKLINE": "Defensor", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "Suplente", + "options.teamMemberRole.COACH": "Entrenador", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Ya existe un equipo con ese nombre", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Grupo de armas", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/es-US/team.json b/locales/es-US/team.json index c3b5c9aff..26d8bd8e9 100644 --- a/locales/es-US/team.json +++ b/locales/es-US/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Abandonar", "actionButtons.editTeam": "Editar Equipo", - "actionButtons.manageRoster": "Manejar Miembros", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Eliminar Equipo", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "El equipo esta lleno", "roster.inviteLink.header": "Comparte el link de invitación para agregar más miembros", "roster.members.header": "Miembros", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "El código de invitación no tiene la longitud correcta. ¿Copiaste la URL completa?", "validation.TEAM_FULL": "El equipo al que te intentas unir ya esta lleno.", "validation.INVITE_CODE_WRONG": "El código de invitación es incorrecto.", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index a992874cc..620874431 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Capitaine", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Support", + "options.teamMemberRole.MIDLINE": "Midline", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "Coach", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Il y a déjà une équipe avec ce nom", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Armes jouées", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/fr-CA/team.json b/locales/fr-CA/team.json index 3d2c10438..e81c014af 100644 --- a/locales/fr-CA/team.json +++ b/locales/fr-CA/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", - "actionButtons.manageRoster": "Gerer la composition de l'équipe", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "L'équipe est complète", "roster.inviteLink.header": "Partager le lien d'invitation pour ajouter des membres", "roster.members.header": "Membres", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. Avez-vous bien copier l'URL au complet ?", "validation.TEAM_FULL": "L'équipe que vous essayez de rejoindre est complète.", "validation.INVITE_CODE_WRONG": "Le code d'invitation est incorrect.", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index fc736d799..3611d5e2d 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "Team Bluesky", + "labels.teamEditor": "Editer", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Capitaine", + "options.teamMemberRole.CO_CAPTAIN": "Co-capitaine", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "Slayer", + "options.teamMemberRole.SKIRMISHER": "Skirmisher", + "options.teamMemberRole.SUPPORT": "Support", + "options.teamMemberRole.MIDLINE": "Midline", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "Sub", + "options.teamMemberRole.COACH": "Coach", + "options.teamMemberRole.CHEERLEADER": "Cheerleader", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Il y a déjà une équipe avec ce nom", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Armes jouées", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/fr-EU/team.json b/locales/fr-EU/team.json index eec7542ab..b9d7cbe52 100644 --- a/locales/fr-EU/team.json +++ b/locales/fr-EU/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "Êtes vous sur de vouloir quitté {{teamName}}? Le nouveau capitaine sera {{newOwner}}", "actionButtons.leaveTeam.confirm": "Quitter", "actionButtons.editTeam": "Modifier l'équipe", - "actionButtons.manageRoster": "Gerer la composition de l'équipe", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Supprimer l'équipe", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "L'équipe est complète", "roster.inviteLink.header": "Partager le lien d'invitation pour ajouter des membres", "roster.members.header": "Membres", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Le code d'invitation n'a pas la bonne longueur. Avez-vous bien copier l'URL au complet ?", "validation.TEAM_FULL": "L'équipe que vous essayez de rejoindre est complète.", "validation.INVITE_CODE_WRONG": "Le code d'invitation est incorrect.", diff --git a/locales/he/forms.json b/locales/he/forms.json index a017518d6..86279774e 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "קפטן", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "קו החזית", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "תמיכה", + "options.teamMemberRole.MIDLINE": "קו אמצע", + "options.teamMemberRole.BACKLINE": "קו אחורי", + "options.teamMemberRole.FLEX": "מעורב", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "מאמן", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "יש כבר צוות בשם הזה", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "מאגר נשקים", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/he/team.json b/locales/he/team.json index 9ed46a4a6..fb1d14223 100644 --- a/locales/he/team.json +++ b/locales/he/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "לעזוב", "actionButtons.editTeam": "עריכת צוות", - "actionButtons.manageRoster": "ניהול חברי צוות", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "מחיקת צוות", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "הצוות מלא", "roster.inviteLink.header": "שיתוף קישור הזמנה להוספת חברי צוות", "roster.members.header": "חברי צוות", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "קוד ההזמנה אינו באורך המתאים. העתקתם את כתובת האתר המלאה?", "validation.TEAM_FULL": "הצוות שאתם מנסים להצטרף אליו מלא.", "validation.INVITE_CODE_WRONG": "קוד ההזמנה שגוי.", diff --git a/locales/it/forms.json b/locales/it/forms.json index 2011c402b..078281cec 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "Bluesky del team", + "labels.teamEditor": "Editor", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Capitano", + "options.teamMemberRole.CO_CAPTAIN": "Co-Capitano", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "Slayer", + "options.teamMemberRole.SKIRMISHER": "Skirmisher", + "options.teamMemberRole.SUPPORT": "Supporto", + "options.teamMemberRole.MIDLINE": "Midline", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "Sostituto", + "options.teamMemberRole.COACH": "Coach", + "options.teamMemberRole.CHEERLEADER": "Cheerleader", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Esiste già un team con questo nome", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Pool armi", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/it/team.json b/locales/it/team.json index 6c265c4f8..4ae935e43 100644 --- a/locales/it/team.json +++ b/locales/it/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "Sei sicur/o di voler lasciare {{teamName}}? Il nuovo proprietario sarà {{newOwner}}", "actionButtons.leaveTeam.confirm": "Lascia", "actionButtons.editTeam": "Modifica team", - "actionButtons.manageRoster": "Gestisci roster", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Delete team", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "Il team è completo", "roster.inviteLink.header": "Condividi link di invito per aggiungere membri", "roster.members.header": "Membri", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Il codice di invito non è della lunghezza giusta. Hai copiato l'intero URL?", "validation.TEAM_FULL": "Il team alla quale hai provato ad unirti è al completo.", "validation.INVITE_CODE_WRONG": "Il codice invito è errato.", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index a68a51864..f71446f15 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "チームの Bluesky", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "キャプテン", + "options.teamMemberRole.CO_CAPTAIN": "キャプテン補佐", + "options.teamMemberRole.FRONTLINE": "前衛", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "前衛(突撃担当)", + "options.teamMemberRole.SUPPORT": "サポート", + "options.teamMemberRole.MIDLINE": "中衛", + "options.teamMemberRole.BACKLINE": "後衛", + "options.teamMemberRole.FLEX": "マルチ", + "options.teamMemberRole.SUB": "補欠", + "options.teamMemberRole.COACH": "コーチ", + "options.teamMemberRole.CHEERLEADER": "引き立て役(チアリーダー)", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "そのチーム名はすでに使用されています", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "使用ブキ", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/ja/team.json b/locales/ja/team.json index 5a954d453..3da18432b 100644 --- a/locales/ja/team.json +++ b/locales/ja/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "チームを抜ける", "actionButtons.editTeam": "チームを編集", - "actionButtons.manageRoster": "名簿管理", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "チームを削除", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "チームは満員です", "roster.inviteLink.header": "招待リンクを共有する", "roster.members.header": "メンバー", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "招待コードの長さが正しくありません。URL をすべてコピーしましたか?", "validation.TEAM_FULL": "参加しようとしているチームはすでに満員です。", "validation.INVITE_CODE_WRONG": "招待コードが正しくありません。", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index ed02cd00b..73282a26d 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "", + "options.teamMemberRole.MIDLINE": "", + "options.teamMemberRole.BACKLINE": "", + "options.teamMemberRole.FLEX": "", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/ko/team.json b/locales/ko/team.json index dd71fb023..0af5e88ff 100644 --- a/locales/ko/team.json +++ b/locales/ko/team.json @@ -49,6 +49,8 @@ "roster.teamFull": "", "roster.inviteLink.header": "", "roster.members.header": "", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "", "validation.TEAM_FULL": "", "validation.INVITE_CODE_WRONG": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 70af47eeb..bc8d7d7cd 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "", + "options.teamMemberRole.MIDLINE": "", + "options.teamMemberRole.BACKLINE": "", + "options.teamMemberRole.FLEX": "", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/nl/team.json b/locales/nl/team.json index dd71fb023..0af5e88ff 100644 --- a/locales/nl/team.json +++ b/locales/nl/team.json @@ -49,6 +49,8 @@ "roster.teamFull": "", "roster.inviteLink.header": "", "roster.members.header": "", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "", "validation.TEAM_FULL": "", "validation.INVITE_CODE_WRONG": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 4e673c27b..e49280230 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Kapitan", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Frontline", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Support", + "options.teamMemberRole.MIDLINE": "", + "options.teamMemberRole.BACKLINE": "Backline", + "options.teamMemberRole.FLEX": "", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "Trener", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Istnieje już drużyna o tym imieniu", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Pula broni", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/pl/team.json b/locales/pl/team.json index 28b3d8685..35510c75d 100644 --- a/locales/pl/team.json +++ b/locales/pl/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Opuść", "actionButtons.editTeam": "Edytuj Drużynę", - "actionButtons.manageRoster": "Zarządzaj Członkami", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Usuń drużynę", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "Drużyna jest pełna", "roster.inviteLink.header": "Udostępnij link by dodać członków", "roster.members.header": "Członkowie", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Zaproszenie jest złej długości. Czy napewno skopiowałeś/aś pełne URL?", "validation.TEAM_FULL": "Drużyna, do której próbujesz dołączyć, jest pełna.", "validation.INVITE_CODE_WRONG": "Zaproszenie niepoprawne.", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index ebb83a97f..97134c75d 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Capitão(ã)", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "Linha de frente (frontline)", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "Suporte", + "options.teamMemberRole.MIDLINE": "Linha média (midline)", + "options.teamMemberRole.BACKLINE": "Linha de trás (backline)", + "options.teamMemberRole.FLEX": "Flex", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "Treinador", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Já existe um time com esse nome", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Seleção de armas", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/pt-BR/team.json b/locales/pt-BR/team.json index 71ece4e7d..09ad553ed 100644 --- a/locales/pt-BR/team.json +++ b/locales/pt-BR/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "Sair", "actionButtons.editTeam": "Editar Time", - "actionButtons.manageRoster": "Gerenciar a lista", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Apagar Time", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "O time está cheio", "roster.inviteLink.header": "Compartilhe o link de convite para adicionar membros", "roster.members.header": "Membros", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "O código de convite não está no comprimento certo. Você copiou o URL completo?", "validation.TEAM_FULL": "O time que você está tentando se juntar está cheio.", "validation.INVITE_CODE_WRONG": "O código de convite está incorreto.", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index a5e4fa339..93a3a2da7 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "Bluesky команды", + "labels.teamEditor": "Редактор", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "Капитан", + "options.teamMemberRole.CO_CAPTAIN": "Со-капитан", + "options.teamMemberRole.FRONTLINE": "Фронтлайн", + "options.teamMemberRole.SLAYER": "Слеер", + "options.teamMemberRole.SKIRMISHER": "Скёрмишер", + "options.teamMemberRole.SUPPORT": "Поддержка", + "options.teamMemberRole.MIDLINE": "Мидлайн", + "options.teamMemberRole.BACKLINE": "Бэклайн", + "options.teamMemberRole.FLEX": "Флекс", + "options.teamMemberRole.SUB": "Запасной", + "options.teamMemberRole.COACH": "Тренер", + "options.teamMemberRole.CHEERLEADER": "Чирлидер", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "Уже существует команда с таким названием", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "Используемое оружие", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/ru/team.json b/locales/ru/team.json index 3778765da..f58d24a08 100644 --- a/locales/ru/team.json +++ b/locales/ru/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "Вы точно хотите покинуть {{teamName}}? Новым владельцем будет {{newOwner}}", "actionButtons.leaveTeam.confirm": "Покинуть", "actionButtons.editTeam": "Редактировать команду", - "actionButtons.manageRoster": "Управлять составом", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "Удалить команду", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "Команда заполнена", "roster.inviteLink.header": "Поделитесь ссылкой на приглашение, чтобы добавить участников", "roster.members.header": "Участники", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "Код приглашения неверной длинны. Вы скопировали URL целиком?", "validation.TEAM_FULL": "Команда, в которую вы пытаетесь попасть, уже заполнена.", "validation.INVITE_CODE_WRONG": "Неверный код приглашения.", diff --git a/locales/zh/analyzer.json b/locales/zh/analyzer.json index cd88f3f21..724028703 100644 --- a/locales/zh/analyzer.json +++ b/locales/zh/analyzer.json @@ -93,8 +93,6 @@ "damage.header.baseDamage": "基础伤害", "damage.header.baseDamage.short": "基础", "damage.header.distance": "距离", - "damage.toSplat_one": "{{count}} 确击倒", - "damage.toSplat_other": "{{count}} 确击倒", "damage.NORMAL_MIN": "最小值", "damage.NORMAL_MAX": "最大值", "damage.SECONDARY_MODE_MIN": "第二射击模式 (最小值)", @@ -164,8 +162,6 @@ "labels.weapon": "武器", "dmgHtdExplanation": "DMG = 伤害 | HTD = 破坏所需伤害", "noDmgData": "目前没有该武器的伤害数据。请期待后续更新!", - "perInkTankGrid.header_one": "使用 {{count}} 次次要武器后的 {{weapon}} 剩余攻击次数", - "perInkTankGrid.header_other": "使用 {{count}} 次次要武器后的 {{weapon}} 剩余攻击次数", "bigBubblerExplanation": "{{weapon}} 的持续时间也会一起增加。", "button.showChart": "显示图表", "button.showConsumptionGrid": "显示耗墨量网格", @@ -185,8 +181,6 @@ "comp.pickWeapon": "选择一个武器", "comp.showWeaponGrid": "显示武器选择器", "comp.hideWeaponGrid": "隐藏武器选择器", - "comp.hits_one": "命中 {{count}} 次", - "comp.hits_other": "命中 {{count}} 次", "comp.enemyRes": "对手的 减轻对手墨汁影响", "comp.enemySubDef": "对手的 减轻次要武器影响", "comp.weaponRanges": "武器射程", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index d90599a17..06a2a6f7b 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -6,6 +6,10 @@ "labels.banner": "", "labels.tag": "", "labels.teamBsky": "", + "labels.teamEditor": "", + "labels.teamMemberRole": "", + "labels.teamMemberCustomRole": "", + "labels.teamMemberRoleType": "", "labels.clockFormat": "", "labels.disableBuildAbilitySorting": "", "labels.disallowScrimPickupsFromUntrusted": "", @@ -23,6 +27,21 @@ "options.clockFormat.auto": "", "options.clockFormat.24h": "", "options.clockFormat.12h": "", + "options.teamMemberRole.CAPTAIN": "队长", + "options.teamMemberRole.CO_CAPTAIN": "", + "options.teamMemberRole.FRONTLINE": "前排", + "options.teamMemberRole.SLAYER": "", + "options.teamMemberRole.SKIRMISHER": "", + "options.teamMemberRole.SUPPORT": "辅助", + "options.teamMemberRole.MIDLINE": "中排", + "options.teamMemberRole.BACKLINE": "后排", + "options.teamMemberRole.FLEX": "自由人", + "options.teamMemberRole.SUB": "", + "options.teamMemberRole.COACH": "教练", + "options.teamMemberRole.CHEERLEADER": "", + "options.teamMemberRole.CUSTOM": "", + "options.teamMemberRoleType.PLAYER": "", + "options.teamMemberRoleType.OTHER": "", "errors.required": "", "errors.minLength": "", "errors.maxLength": "", @@ -32,6 +51,9 @@ "errors.duplicateName": "该队名已被使用", "errors.duplicateOrgName": "", "errors.noOnlySpecialCharacters": "", + "errors.customRoleRequired": "", + "errors.customRoleTypeRequired": "", + "errors.customRoleOnlyWhenCustom": "", "labels.weaponPool": "武器池", "placeholders.weaponPoolFull": "", "labels.voiceChat": "", diff --git a/locales/zh/team.json b/locales/zh/team.json index cea6957c3..bc4e2afea 100644 --- a/locales/zh/team.json +++ b/locales/zh/team.json @@ -10,7 +10,7 @@ "leaveTeam.header.newOwner": "", "actionButtons.leaveTeam.confirm": "退出", "actionButtons.editTeam": "编辑队伍", - "actionButtons.manageRoster": "管理阵容", + "actionButtons.manageRoster": "", "actionButtons.deleteTeam": "删除队伍", "actionButtons.deleteTeam.profilePicture": "", "actionButtons.deleteTeam.banner": "", @@ -49,6 +49,8 @@ "roster.teamFull": "成员已满", "roster.inviteLink.header": "分享邀请链接来添加成员", "roster.members.header": "成员", + "roster.sections.players": "", + "roster.sections.other": "", "validation.SHORT_CODE": "邀请码长度不符,请确保您复制了完整的URL。", "validation.TEAM_FULL": "您想要加入的队伍成员已满。", "validation.INVITE_CODE_WRONG": "邀请码有误。", diff --git a/migrations/150-team-member-custom-roles.js b/migrations/150-team-member-custom-roles.js new file mode 100644 index 000000000..6bcc66f8a --- /dev/null +++ b/migrations/150-team-member-custom-roles.js @@ -0,0 +1,10 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "AllTeamMember" add "customRole" text`, + ).run(); + db.prepare( + /* sql */ `alter table "AllTeamMember" add "roleType" text`, + ).run(); + })(); +}