mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 06:58:10 -05:00
Team: Manage roster page
This commit is contained in:
parent
8a3178413c
commit
1170dc0bd0
|
|
@ -1,3 +1,4 @@
|
|||
import type { TEAM_MEMBER_ROLES } from "~/features/team";
|
||||
import type {
|
||||
Ability,
|
||||
MainWeaponId,
|
||||
|
|
@ -255,12 +256,7 @@ export interface Team {
|
|||
deletedAt: number | null;
|
||||
}
|
||||
|
||||
export type MemberRole =
|
||||
| "CAPTAIN"
|
||||
| "FRONTLINE"
|
||||
| "SUPPORT"
|
||||
| "BACKLINE"
|
||||
| "COACH";
|
||||
export type MemberRole = typeof TEAM_MEMBER_ROLES[number];
|
||||
|
||||
export interface TeamMember {
|
||||
teamId: number;
|
||||
|
|
|
|||
1
app/features/team/index.ts
Normal file
1
app/features/team/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TEAM_MEMBER_ROLES } from "./team-constants";
|
||||
16
app/features/team/queries/addNewTeamMember.server.ts
Normal file
16
app/features/team/queries/addNewTeamMember.server.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
insert into "AllTeamMember"
|
||||
("teamId", "userId")
|
||||
values
|
||||
(@teamId, @userId)
|
||||
on conflict("teamId", "userId") do
|
||||
update
|
||||
set
|
||||
"leftAt" = null
|
||||
`);
|
||||
|
||||
export function addNewTeamMember(teamId: number, userId: number) {
|
||||
stm.run({ teamId, userId });
|
||||
}
|
||||
21
app/features/team/queries/editRole.server.ts
Normal file
21
app/features/team/queries/editRole.server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { MemberRole } from "~/db/types";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "AllTeamMember"
|
||||
set "role" = @role
|
||||
where "teamId" = @teamId
|
||||
and "userId" = @userId
|
||||
`);
|
||||
|
||||
export function editRole({
|
||||
userId,
|
||||
teamId,
|
||||
role,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
role: MemberRole | null;
|
||||
}) {
|
||||
return stm.run({ userId, teamId, role });
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ const membersStm = sql.prepare(/*sql*/ `
|
|||
"User"."discordName",
|
||||
"User"."discordAvatar",
|
||||
"User"."discordId",
|
||||
"User"."discordDiscriminator",
|
||||
"TeamMember"."role",
|
||||
"TeamMember"."isOwner",
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
|
|
@ -47,7 +48,14 @@ type TeamRow =
|
|||
| null;
|
||||
|
||||
type MemberRows = Array<
|
||||
Pick<User, "id" | "discordName" | "discordAvatar" | "discordId"> &
|
||||
Pick<
|
||||
User,
|
||||
| "id"
|
||||
| "discordName"
|
||||
| "discordAvatar"
|
||||
| "discordId"
|
||||
| "discordDiscriminator"
|
||||
> &
|
||||
Pick<TeamMember, "role" | "isOwner"> & { weapons: string }
|
||||
>;
|
||||
|
||||
|
|
@ -72,6 +80,7 @@ export function findByIdentifier(customUrl: string): DetailedTeam | null {
|
|||
discordAvatar: member.discordAvatar,
|
||||
discordId: member.discordId,
|
||||
discordName: member.discordName,
|
||||
discordDiscriminator: member.discordDiscriminator,
|
||||
role: member.role ?? undefined,
|
||||
isOwner: Boolean(member.isOwner),
|
||||
weapons: JSON.parse(member.weapons).filter(Boolean),
|
||||
|
|
|
|||
11
app/features/team/queries/inviteCodeById.server.ts
Normal file
11
app/features/team/queries/inviteCodeById.server.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select "inviteCode"
|
||||
from "Team"
|
||||
where "id" = @teamId
|
||||
`);
|
||||
|
||||
export function inviteCodeById(teamId: number): string | null {
|
||||
return stm.get({ teamId })?.inviteCode ?? null;
|
||||
}
|
||||
13
app/features/team/queries/resetInviteLink.server.ts
Normal file
13
app/features/team/queries/resetInviteLink.server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { nanoid } from "nanoid";
|
||||
import { INVITE_CODE_LENGTH } from "~/constants";
|
||||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "AllTeam"
|
||||
set "inviteCode" = @inviteCode
|
||||
where "id" = @teamId
|
||||
`);
|
||||
|
||||
export function resetInviteLink(teamId: number) {
|
||||
stm.run({ teamId, inviteCode: nanoid(INVITE_CODE_LENGTH) });
|
||||
}
|
||||
23
app/features/team/queries/transferOwnership.server.ts
Normal file
23
app/features/team/queries/transferOwnership.server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "AllTeamMember"
|
||||
set "isOwner" = @isOwner
|
||||
where "teamId" = @teamId
|
||||
and "userId" = @userId
|
||||
`);
|
||||
|
||||
export const transferOwnership = sql.transaction(
|
||||
({
|
||||
teamId,
|
||||
oldOwnerUserId,
|
||||
newOwnerUserId,
|
||||
}: {
|
||||
teamId: number;
|
||||
oldOwnerUserId: number;
|
||||
newOwnerUserId: number;
|
||||
}) => {
|
||||
stm.run({ teamId, userId: oldOwnerUserId, isOwner: 0 });
|
||||
stm.run({ teamId, userId: newOwnerUserId, isOwner: 1 });
|
||||
}
|
||||
);
|
||||
|
|
@ -27,6 +27,7 @@ import { FormErrors } from "~/components/FormErrors";
|
|||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { deleteTeam } from "../queries/deleteTeam.server";
|
||||
import { Button } from "~/components/Button";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
|
|
@ -75,6 +76,9 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
|
||||
return redirect(teamPage(editedTeam.customUrl));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,255 @@
|
|||
import type { LinksFunction } from "@remix-run/node";
|
||||
import {
|
||||
redirect,
|
||||
type ActionFunction,
|
||||
type LoaderArgs,
|
||||
} from "@remix-run/node";
|
||||
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useBaseUrl } from "~/hooks/useBaseUrl";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { requireUser, useUser } from "~/modules/auth";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { discordFullName } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { joinTeamPage, teamPage } from "~/utils/urls";
|
||||
import { editRole } from "../queries/editRole.server";
|
||||
import { findByIdentifier } from "../queries/findByIdentifier.server";
|
||||
import { inviteCodeById } from "../queries/inviteCodeById.server";
|
||||
import { leaveTeam } from "../queries/leaveTeam.server";
|
||||
import { resetInviteLink } from "../queries/resetInviteLink.server";
|
||||
import { transferOwnership } from "../queries/transferOwnership.server";
|
||||
import { TEAM_MEMBER_ROLES } from "../team-constants";
|
||||
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
|
||||
import type { DetailedTeamMember } from "../team-types";
|
||||
import { isTeamFull, isTeamOwner } from "../team-utils";
|
||||
import styles from "../team.css";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
validate(isTeamOwner({ team, user }));
|
||||
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: manageRosterSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_MEMBER": {
|
||||
validate(data.userId !== user.id);
|
||||
leaveTeam({ teamId: team.id, userId: data.userId });
|
||||
break;
|
||||
}
|
||||
case "RESET_INVITE_LINK": {
|
||||
resetInviteLink(team.id);
|
||||
break;
|
||||
}
|
||||
case "TRANSFER_OWNERSHIP": {
|
||||
transferOwnership({
|
||||
teamId: team.id,
|
||||
newOwnerUserId: data.newOwnerId,
|
||||
oldOwnerUserId: user.id,
|
||||
});
|
||||
|
||||
return redirect(teamPage(customUrl));
|
||||
}
|
||||
case "UPDATE_MEMBER_ROLE": {
|
||||
editRole({
|
||||
role: data.role || null,
|
||||
teamId: team.id,
|
||||
userId: data.userId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["team"],
|
||||
// breadcrumb: () => ({
|
||||
// imgPath: navIconUrl("object-damage-calculator"),
|
||||
// href: OBJECT_DAMAGE_CALCULATOR_URL,
|
||||
// type: "IMAGE",
|
||||
// }),
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfFalsy(findByIdentifier(customUrl));
|
||||
|
||||
if (!isTeamOwner({ team, user })) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
team: { ...team, inviteCode: inviteCodeById(team.id)! },
|
||||
};
|
||||
};
|
||||
|
||||
export default function ManageTeamRosterPage() {
|
||||
return <Main>manage it!</Main>;
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<InviteCodeSection />
|
||||
<MemberActions />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function InviteCodeSection() {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
const baseUrl = useBaseUrl();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (isTeamFull(team)) {
|
||||
return (
|
||||
<Alert variation="INFO" alertClassName="mx-auto w-max">
|
||||
{t("team:roster.teamFull")}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const inviteLink = `${baseUrl}${joinTeamPage({
|
||||
customUrl: team.customUrl,
|
||||
inviteCode: team.inviteCode,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg">{t("team:roster.inviteLink.header")}</h2>
|
||||
<div className="stack md">
|
||||
<div className="text-sm">{inviteLink}</div>
|
||||
<Form method="post" className="stack horizontal md">
|
||||
<Button size="tiny" onClick={() => copyToClipboard(inviteLink)}>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</Button>
|
||||
<SubmitButton
|
||||
variant="minimal-destructive"
|
||||
_action="RESET_INVITE_LINK"
|
||||
size="tiny"
|
||||
>
|
||||
{t("common:actions.reset")}
|
||||
</SubmitButton>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberActions() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<h2 className="text-lg">{t("team:roster.members.header")}</h2>
|
||||
|
||||
<div className="team__roster__members">
|
||||
{team.members.map((member) => (
|
||||
<MemberRow key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NO_ROLE = "NO_ROLE";
|
||||
function MemberRow({ member }: { member: DetailedTeamMember }) {
|
||||
const { team } = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["team"]);
|
||||
const user = useUser();
|
||||
const roleFetcher = useFetcher();
|
||||
|
||||
const isSelf = user!.id === member.id;
|
||||
const role = team.members.find((m) => m.id === member.id)?.role ?? NO_ROLE;
|
||||
|
||||
return (
|
||||
<React.Fragment key={member.id}>
|
||||
<div className="team__roster__members__member">
|
||||
{discordFullName(member)}
|
||||
</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"}
|
||||
>
|
||||
<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: isSelf })}>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("team:kick.header", {
|
||||
teamName: team.name,
|
||||
user: discordFullName(member),
|
||||
})}
|
||||
deleteButtonText={t("team:actionButtons.kick")}
|
||||
fields={[
|
||||
["_action", "DELETE_MEMBER"],
|
||||
["userId", member.id],
|
||||
]}
|
||||
>
|
||||
<Button size="tiny" variant="minimal-destructive">
|
||||
{t("team:actionButtons.kick")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
<div className={clsx({ invisible: isSelf })}>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("team:transferOwnership.header", {
|
||||
teamName: team.name,
|
||||
user: discordFullName(member),
|
||||
})}
|
||||
deleteButtonText={t("team:actionButtons.transferOwnership.confirm")}
|
||||
fields={[
|
||||
["_action", "TRANSFER_OWNERSHIP"],
|
||||
["newOwnerId", member.id],
|
||||
]}
|
||||
>
|
||||
<Button size="tiny" variant="minimal-destructive">
|
||||
{t("team:actionButtons.transferOwnership")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
</div>
|
||||
<hr className="team__roster__separator" />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,4 +3,13 @@ export const TEAM = {
|
|||
NAME_MIN_LENGTH: 2,
|
||||
BIO_MAX_LENGTH: 2000,
|
||||
TWITTER_MAX_LENGTH: 50,
|
||||
MAX_MEMBER_COUNT: 8,
|
||||
};
|
||||
|
||||
export const TEAM_MEMBER_ROLES = [
|
||||
"CAPTAIN",
|
||||
"FRONTLINE",
|
||||
"SUPPORT",
|
||||
"BACKLINE",
|
||||
"COACH",
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { falsyToNull } from "~/utils/zod";
|
||||
import { TEAM } from "./team-constants";
|
||||
import { falsyToNull, id } from "~/utils/zod";
|
||||
import { TEAM, TEAM_MEMBER_ROLES } from "./team-constants";
|
||||
|
||||
export const teamParamsSchema = z.object({ customUrl: z.string() });
|
||||
|
||||
|
|
@ -21,3 +21,22 @@ export const editTeamSchema = z.union([
|
|||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const manageRosterSchema = z.union([
|
||||
z.object({
|
||||
_action: z.literal("RESET_INVITE_LINK"),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("DELETE_MEMBER"),
|
||||
userId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("TRANSFER_OWNERSHIP"),
|
||||
newOwnerId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("UPDATE_MEMBER_ROLE"),
|
||||
userId: id,
|
||||
role: z.union([z.enum(TEAM_MEMBER_ROLES), z.literal("")]),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export interface DetailedTeamMember {
|
|||
discordName: string;
|
||||
discordId: string;
|
||||
discordAvatar: string | null;
|
||||
discordDiscriminator: string;
|
||||
isOwner: boolean;
|
||||
weapons: MainWeaponId[];
|
||||
role?: MemberRole;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { TEAM } from "./team-constants";
|
||||
import type { DetailedTeam } from "./team-types";
|
||||
|
||||
export function isTeamOwner({
|
||||
|
|
@ -23,3 +24,7 @@ export function isTeamMember({
|
|||
|
||||
return team.members.some((member) => member.id === user.id);
|
||||
}
|
||||
|
||||
export function isTeamFull(team: DetailedTeam) {
|
||||
return team.members.length >= TEAM.MAX_MEMBER_COUNT;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,25 @@
|
|||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.team__roster__members {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--s-4);
|
||||
place-items: center;
|
||||
max-width: max-content;
|
||||
}
|
||||
|
||||
.team__roster__members__member {
|
||||
justify-self: flex-start;
|
||||
font-weight: var(--bold);
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
||||
.team__roster__separator {
|
||||
grid-column: 1 / 3;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.team__banner__flags {
|
||||
display: flex;
|
||||
|
|
@ -220,4 +239,12 @@
|
|||
.team__member-card__container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.team__roster__members {
|
||||
grid-template-columns: 1fr 1fr max-content max-content;
|
||||
}
|
||||
|
||||
.team__roster__separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
app/hooks/useBaseUrl.ts
Normal file
7
app/hooks/useBaseUrl.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { useMatches } from "@remix-run/react";
|
||||
|
||||
export function useBaseUrl() {
|
||||
const matches = useMatches();
|
||||
|
||||
return matches[0]?.data["baseUrl"] as string;
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ export interface RootLoaderData {
|
|||
locale: string;
|
||||
theme: Theme | null;
|
||||
patrons: FindAllPatrons;
|
||||
baseUrl: string;
|
||||
user?: Pick<
|
||||
UserWithPlusTier,
|
||||
| "id"
|
||||
|
|
@ -103,6 +104,7 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||
locale,
|
||||
theme: themeSession.getTheme(),
|
||||
patrons: db.users.findAllPatrons(),
|
||||
baseUrl: process.env["BASE_URL"],
|
||||
user: user
|
||||
? {
|
||||
discordName: user.discordName,
|
||||
|
|
|
|||
|
|
@ -272,6 +272,12 @@ select {
|
|||
padding-inline: var(--s-3) var(--s-8);
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
/* Temporary solution for issue: https://github.com/Sendouc/sendou.ink/issues/1141 */
|
||||
.light select {
|
||||
/* TODO: Get color from CSS var */
|
||||
|
|
|
|||
|
|
@ -106,6 +106,13 @@ export const editTeamPage = (customUrl: string) =>
|
|||
`${teamPage(customUrl)}/edit`;
|
||||
export const manageTeamRosterPage = (customUrl: string) =>
|
||||
`${teamPage(customUrl)}/roster`;
|
||||
export const joinTeamPage = ({
|
||||
customUrl,
|
||||
inviteCode,
|
||||
}: {
|
||||
customUrl: string;
|
||||
inviteCode: string;
|
||||
}) => `${teamPage(customUrl)}/join?code=${inviteCode}`;
|
||||
|
||||
export const authErrorUrl = (errorCode: AuthErrorCode) =>
|
||||
`/?authError=${errorCode}`;
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"actions.add": "Add",
|
||||
"actions.remove": "Remove",
|
||||
"actions.delete": "Delete",
|
||||
"actions.reset": "Reset",
|
||||
"actions.loadMore": "Load more",
|
||||
"actions.copyToClipboard": "Copy to clipboard",
|
||||
"actions.close": "Close",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
"actionButtons.editTeam": "Edit Team",
|
||||
"actionButtons.manageRoster": "Manage Roster",
|
||||
"actionButtons.deleteTeam": "Delete Team",
|
||||
"actionButtons.kick": "Kick",
|
||||
"kick.header": "Kick {{user}} from {{teamName}}?",
|
||||
"actionButtons.transferOwnership": "Transfer Ownership",
|
||||
"transferOwnership.header": "Transfer ownership of {{teamName}} to {{user}}?",
|
||||
"actionButtons.transferOwnership.confirm": "Transfer",
|
||||
"deleteTeam.header": "Are you sure you want to delete {{teamName}}?",
|
||||
"roles.CAPTAIN": "Captain",
|
||||
"roles.FRONTLINE": "Frontline",
|
||||
|
|
@ -14,5 +19,8 @@
|
|||
"forms.fields.teamTwitter": "Team Twitter",
|
||||
"forms.fields.bio": "Bio",
|
||||
"forms.info.name": "Note that if you change your team's name then someone else can claim the name and URL for their team",
|
||||
"forms.errors.duplicateName": "There is already a team with this name"
|
||||
"forms.errors.duplicateName": "There is already a team with this name",
|
||||
"roster.teamFull": "Team is full",
|
||||
"roster.inviteLink.header": "Share invite link to add members",
|
||||
"roster.members.header": "Members"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user