Team SendouForm finalized, custom roles (#3156)
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle
2026-06-14 09:53:18 +03:00
committed by GitHub
parent 0f1b9b962c
commit 4c50ef0e7e
66 changed files with 894 additions and 343 deletions

View File

@@ -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<T> =
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<number>;
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<DB[P]> };
export type TablesInsertable = { [P in keyof DB]: Insertable<DB[P]> };
export type TablesUpdatable = { [P in keyof DB]: Updateable<DB[P]> };
export interface DB {
AllTeam: Team;

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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<DB>,
{
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();
}
}

View File

@@ -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);

View File

@@ -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]) => (
<React.Fragment key={member.discordId}>
<MemberRow member={member} number={members.indexOf(member)} />
<MobileMemberCard member={member} />
</React.Fragment>
);
return (
<div className="stack lg">
<ActionButtons />
@@ -39,14 +72,34 @@ export default function TeamIndexPage() {
{layoutData.team.bio ? (
<article data-testid="team-bio">{layoutData.team.bio}</article>
) : null}
<div className="stack lg">
{layoutData.team.members.map((member, i) => (
<React.Fragment key={member.discordId}>
<MemberRow member={member} number={i} />
<MobileMemberCard member={member} />
</React.Fragment>
))}
</div>
{showSections ? (
<SendouTabs>
<SendouTabList>
<SendouTab
id="players"
icon={<Swords />}
number={playerMembers.length}
>
{t("team:roster.sections.players")}
</SendouTab>
<SendouTab
id="other"
icon={<Wrench />}
number={otherMembers.length}
>
{t("team:roster.sections.other")}
</SendouTab>
</SendouTabList>
<SendouTabPanel id="players">
<div className="stack lg">{playerMembers.map(renderMember)}</div>
</SendouTabPanel>
<SendouTabPanel id="other">
<div className="stack lg">{otherMembers.map(renderMember)}</div>
</SendouTabPanel>
</SendouTabs>
) : (
<div className="stack lg">{members.map(renderMember)}</div>
)}
</div>
);
}
@@ -274,7 +327,14 @@ function MemberRow({
className={styles.member}
data-testid={member.isOwner ? `member-owner-${member.id}` : undefined}
>
{member.role ? (
{member.customRole ? (
<span
className={styles.memberRole}
data-testid={`member-row-role-${number}`}
>
{member.customRole}
</span>
) : member.role ? (
<span
className={styles.memberRole}
data-testid={`member-row-role-${number}`}
@@ -334,7 +394,9 @@ function MobileMemberCard({
</div>
) : null}
</div>
{member.role ? (
{member.customRole ? (
<span className={styles.memberRoleMobile}>{member.customRole}</span>
) : member.role ? (
<span className={styles.memberRoleMobile}>
{t(`team:roles.${member.role}`)}
</span>

View File

@@ -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 (
<Main className="stack lg">
<Main halfWidth className="stack lg">
<TeamGoBackButton />
<InviteCodeSection />
<MemberActions />
@@ -105,129 +102,72 @@ function InviteCodeSection() {
}
function MemberActions() {
const { t } = useTranslation(["team"]);
const { t } = useTranslation(["common", "team"]);
const { team } = useLoaderData<typeof loader>();
const user = useUser();
const isProtectedMember = (member: { id: number; isOwner: number }) =>
Boolean(member.isOwner) || member.id === user!.id;
return (
<div className="stack md">
<h2 className="text-lg">{t("team:roster.members.header")}</h2>
<div className={styles.rosterMembers}>
{team.members.map((member, i) => (
<MemberRow key={member.id} member={member} number={i} />
))}
</div>
<SendouForm
fullWidth
schema={updateRosterSchema}
submitButtonText={t("common:actions.save")}
defaultValues={{
members: team.members.map((member) => ({
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 }) => (
<FormField
name="members"
canRemoveItem={(item) => {
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 (
<div
className="stack md-plus"
data-testid={`member-row-${index}`}
>
<div className="stack horizontal sm items-center text-sm font-bold mb-2">
{member ? <Avatar size="xs" user={member} /> : null}
{member?.username}
</div>
<FormField name={`${itemName}.role`} />
{(values as { role: string | null }).role ===
CUSTOM_ROLE_VALUE ? (
<>
<FormField name={`${itemName}.customRole`} />
<FormField name={`${itemName}.roleType`} />
</>
) : null}
{member && !isProtectedMember(member) ? (
<FormField name={`${itemName}.isManager`} />
) : null}
</div>
);
}}
</FormField>
)}
</SendouForm>
</div>
);
}
const NO_ROLE = "NO_ROLE";
function MemberRow({
member,
number,
}: {
member: TeamRepository.findByCustomUrl["members"][number];
number: number;
}) {
const { team } = useLoaderData<typeof loader>();
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 (
<React.Fragment key={member.id}>
<div className={styles.rosterMember} data-testid={`member-row-${number}`}>
{member.username}
</div>
<div>
<select
defaultValue={role}
onChange={(e) =>
roleFetcher.submit(
{
_action: "UPDATE_MEMBER_ROLE",
userId: String(member.id),
role: e.target.value === NO_ROLE ? "" : e.target.value,
},
{ method: "post" },
)
}
disabled={roleFetcher.state !== "idle"}
data-testid={`role-select-${number}`}
>
<option value={NO_ROLE}>No role</option>
{TEAM_MEMBER_ROLES.map((role) => {
return (
<option key={role} value={role}>
{t(`team:roles.${role}`)}
</option>
);
})}
</select>
</div>
<div className={clsx({ invisible: isThisMemberOwner || isSelf })}>
<SendouSwitch
onChange={(isSelected) =>
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")}
</SendouSwitch>
</div>
<div className={clsx({ invisible: isThisMemberOwner || isSelf })}>
<FormWithConfirm
dialogHeading={t("team:kick.header", {
teamName: team.name,
user: member.username,
})}
submitButtonText={t("team:actionButtons.kick")}
fields={[
["_action", "DELETE_MEMBER"],
["userId", member.id],
]}
>
<SendouButton
size="small"
variant="destructive"
icon={<Trash />}
data-testid={!isSelf ? "kick-button" : undefined}
>
{t("team:actionButtons.kick")}
</SendouButton>
</FormWithConfirm>
</div>
<hr className={styles.rosterSeparator} />
</React.Fragment>
);
}

View File

@@ -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;

View File

@@ -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("")]),
}),
]);

View File

@@ -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",
});
}
}
});

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -155,6 +155,7 @@ function TeamRow({
<tr
className={clsx({ [styles.droppedOut]: team.droppedOut })}
data-testid="team-row"
data-team-id={team.id}
>
<td>
<div className="stack horizontal sm items-center">

View File

@@ -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",
),

View File

@@ -249,7 +249,11 @@ function TeamInfo() {
) : null}
<div>
{data.user.team.name}
{data.user.team.userTeamRole ? (
{data.user.team.userTeamCustomRole ? (
<div className="text-xxs text-lighter font-bold">
{data.user.team.userTeamCustomRole}
</div>
) : data.user.team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${data.user.team.userTeamRole}`)}
</div>
@@ -310,7 +314,11 @@ function SecondaryTeamsPopover() {
) : null}
{team.name}
</Link>
{team.userTeamRole ? (
{team.userTeamCustomRole ? (
<div className="text-xxs text-lighter font-bold">
{team.userTeamCustomRole}
</div>
) : team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${team.userTeamRole}`)}
</div>

View File

@@ -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<string, unknown>[];

View File

@@ -16,6 +16,8 @@ type ArrayFormFieldProps = Omit<FormFieldProps<"array">, "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 ? (
<FormMessage type="info">{translatedBottomText}</FormMessage>
) : null}
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
onPress={handleAdd}
isDisabled={count >= max}
className="m-0-auto"
>
{t("common:actions.add")}
</SendouButton>
{addable ? (
<SendouButton
size="small"
variant="outlined"
icon={<Plus />}
onPress={handleAdd}
isDisabled={count >= max}
className="m-0-auto"
>
{t("common:actions.add")}
</SendouButton>
) : null}
</div>
);
}

View File

@@ -129,6 +129,8 @@ export interface FormFieldArray<T extends string, S extends z.ZodType>
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<T extends string> extends FormFieldBase<T> {
@@ -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);

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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") });

View File

@@ -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)",
);

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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": "קוד ההזמנה שגוי.",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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": "招待コードが正しくありません。",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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.",

View File

@@ -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": "",

View File

@@ -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": "Неверный код приглашения.",

View File

@@ -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": "武器射程",

View File

@@ -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": "",

View File

@@ -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": "邀请码有误。",

View File

@@ -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();
})();
}