-
+
setSearchParams((prev) => {
prev.set(OPEN_COMMISIONS_KEY, String(!showOpenCommissions));
return prev;
diff --git a/app/features/build-analyzer/components/PerInkTankGrid.tsx b/app/features/build-analyzer/components/PerInkTankGrid.tsx
index db81ada65..4938cf565 100644
--- a/app/features/build-analyzer/components/PerInkTankGrid.tsx
+++ b/app/features/build-analyzer/components/PerInkTankGrid.tsx
@@ -21,7 +21,7 @@ export function PerInkTankGrid(props: PerInkTankGridProps) {
+
{t("analyzer:button.showConsumptionGrid")}
}
diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx
index dad8b227f..ae789c1cc 100644
--- a/app/features/build-analyzer/routes/analyzer.tsx
+++ b/app/features/build-analyzer/routes/analyzer.tsx
@@ -12,7 +12,6 @@ import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Table } from "~/components/Table";
import { Tab, Tabs } from "~/components/Tabs";
-import { Toggle } from "~/components/Toggle";
import { BeakerIcon } from "~/components/icons/Beaker";
import { MAX_AP } from "~/constants";
import { useUser } from "~/features/auth/core/user";
@@ -80,8 +79,8 @@ import {
isMainOnlyAbility,
isStackableAbility,
} from "../core/utils";
-
import "../analyzer.css";
+import { SendouSwitch } from "~/components/elements/Switch";
export const CURRENT_PATCH = "9.2";
@@ -1299,14 +1298,14 @@ function EffectsSelector({
})}
) : (
-
- checked
+
+ isSelected
? handleAddEffect(effect.type)
: handleRemoveEffect(effect.type)
}
- tiny
+ size="small"
/>
)}
diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx
index 6e25809b2..9127d9f25 100644
--- a/app/features/calendar/components/BracketProgressionSelector.tsx
+++ b/app/features/calendar/components/BracketProgressionSelector.tsx
@@ -6,7 +6,7 @@ import { DateInput } from "~/components/DateInput";
import { FormMessage } from "~/components/FormMessage";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
-import { Toggle } from "~/components/Toggle";
+import { SendouSwitch } from "~/components/elements/Switch";
import { PlusIcon } from "~/components/icons/Plus";
import { TOURNAMENT } from "~/features/tournament";
import * as Progression from "~/features/tournament-bracket/core/Progression";
@@ -207,12 +207,13 @@ function TournamentFormatBracketSelector({
{bracket.sources ? (
-
- updateBracket({ requiresCheckIn: checked })
+
+ updateBracket({ requiresCheckIn: isSelected })
}
- disabled={bracket.disabled}
+ isDisabled={bracket.disabled}
/>
Check-in starts 1 hour before start time or right after the
@@ -247,14 +248,18 @@ function TournamentFormatBracketSelector({
-
+
updateBracket({
- settings: { ...bracket.settings, thirdPlaceMatch: checked },
+ settings: {
+ ...bracket.settings,
+ thirdPlaceMatch: isSelected,
+ },
})
}
- disabled={bracket.disabled}
+ isDisabled={bracket.disabled}
/>
) : null}
@@ -347,18 +352,19 @@ function TournamentFormatBracketSelector({
{!isFirstBracket ? (
-
+ size="small"
+ isSelected={Boolean(bracket.sources)}
+ onChange={(isSelected) =>
updateBracket({
- sources: checked ? [] : undefined,
+ sources: isSelected ? [] : undefined,
requiresCheckIn: false,
startTime: undefined,
})
}
- disabled={bracket.disabled || isTournamentInProgress}
+ isDisabled={bracket.disabled || isTournamentInProgress}
+ data-testid="follow-up-bracket-switch"
/>
diff --git a/app/features/img-upload/actions/upload.server.ts b/app/features/img-upload/actions/upload.server.ts
index a820678a9..59d2b9497 100644
--- a/app/features/img-upload/actions/upload.server.ts
+++ b/app/features/img-upload/actions/upload.server.ts
@@ -7,8 +7,8 @@ import {
} from "@remix-run/node";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
-import { isTeamOwner } from "~/features/team";
import * as TeamRepository from "~/features/team/TeamRepository.server";
+import { isTeamManager } from "~/features/team/team-utils";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import { canEditTournamentOrganization } from "~/features/tournament-organization/tournament-organization-utils";
import { dateToDatabaseTimestamp } from "~/utils/dates";
@@ -104,8 +104,8 @@ async function validatedTeam({
);
const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl);
validate(
- detailedTeam && isTeamOwner({ team: detailedTeam, user }),
- "You must be the team owner to upload images",
+ detailedTeam && isTeamManager({ team: detailedTeam, user }),
+ "You must be the team manager to upload images",
);
return team;
diff --git a/app/features/img-upload/routes/upload.tsx b/app/features/img-upload/routes/upload.tsx
index e0a058f04..c59f68af3 100644
--- a/app/features/img-upload/routes/upload.tsx
+++ b/app/features/img-upload/routes/upload.tsx
@@ -7,15 +7,14 @@ import { useTranslation } from "react-i18next";
import { Button } from "~/components/Button";
import { Main } from "~/components/Main";
import { requireUser } from "~/features/auth/core/user.server";
-import { isTeamOwner } from "~/features/team";
import * as TeamRepository from "~/features/team/TeamRepository.server";
+import { isTeamManager } from "~/features/team/team-utils";
import invariant from "~/utils/invariant";
+import { action } from "../actions/upload.server";
import { countUnvalidatedImg } from "../queries/countUnvalidatedImg.server";
import { imgTypeToDimensions, imgTypeToStyle } from "../upload-constants";
import type { ImageUploadType } from "../upload-types";
import { requestToImgType } from "../upload-utils";
-
-import { action } from "../actions/upload.server";
export { action };
export const loader = async ({ request }: LoaderFunctionArgs) => {
@@ -38,7 +37,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const detailedTeam = await TeamRepository.findByCustomUrl(team.customUrl);
- if (!detailedTeam || !isTeamOwner({ team: detailedTeam, user })) {
+ if (!detailedTeam || !isTeamManager({ team: detailedTeam, user })) {
throw redirect("/");
}
}
diff --git a/app/features/map-list-generator/routes/maps.tsx b/app/features/map-list-generator/routes/maps.tsx
index db681406a..ac32297c0 100644
--- a/app/features/map-list-generator/routes/maps.tsx
+++ b/app/features/map-list-generator/routes/maps.tsx
@@ -12,7 +12,6 @@ import { Button } from "~/components/Button";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { MapPoolSelector, MapPoolStages } from "~/components/MapPoolSelector";
-import { Toggle } from "~/components/Toggle";
import { EditIcon } from "~/components/icons/Edit";
import type { CalendarEvent } from "~/db/types";
import { getUserId } from "~/features/auth/core/user.server";
@@ -32,8 +31,8 @@ import { generateMapList } from "../core/map-list-generator/map-list";
import { modesOrder } from "../core/map-list-generator/modes";
import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils";
import { MapPool } from "../core/map-pool";
-
import "~/styles/maps.css";
+import { SendouSwitch } from "~/components/elements/Switch";
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
@@ -219,7 +218,11 @@ function MapListCreator({ mapPool }: { mapPool: MapPool }) {
{t("common:maps.halfSz")}
-
+
) : null}
diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx
index 3be157f94..e274704c1 100644
--- a/app/features/sendouq-settings/routes/q.settings.tsx
+++ b/app/features/sendouq-settings/routes/q.settings.tsx
@@ -14,7 +14,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { ModeImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
-import { Toggle } from "~/components/Toggle";
import { CrossIcon } from "~/components/icons/Cross";
import { MapIcon } from "~/components/icons/Map";
import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled";
@@ -52,8 +51,8 @@ import {
SENDOUQ_WEAPON_POOL_MAX_SIZE,
} from "../q-settings-constants";
import { settingsActionSchema } from "../q-settings-schemas.server";
-
import "../q-settings.css";
+import { SendouSwitch } from "~/components/elements/Switch";
export const handle: SendouRouteHandle = {
i18n: ["q"],
@@ -799,9 +798,9 @@ function Misc() {
-
diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx
index fd7800032..aede42d2a 100644
--- a/app/features/sendouq/components/GroupCard.tsx
+++ b/app/features/sendouq/components/GroupCard.tsx
@@ -817,7 +817,7 @@ function VoiceChatInfo({
trigger={
}
/>
}
diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx
index aa1e04230..b5a52c659 100644
--- a/app/features/sendouq/routes/q.match.$id.tsx
+++ b/app/features/sendouq/routes/q.match.$id.tsx
@@ -28,7 +28,6 @@ import { Image, ModeImage, StageImage, WeaponImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { NewTabs } from "~/components/NewTabs";
import { SubmitButton } from "~/components/SubmitButton";
-import { Toggle } from "~/components/Toggle";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
@@ -112,8 +111,8 @@ import { findMatchById } from "../queries/findMatchById.server";
import { reportScore } from "../queries/reportScore.server";
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
-
import "../q.css";
+import { SendouSwitch } from "~/components/elements/Switch";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom
| null;
@@ -733,7 +732,7 @@ function DisputePopover() {
popoverClassName="text-main-forced"
trigger={
@@ -1304,7 +1303,7 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) {
trigger={
@@ -1437,10 +1436,10 @@ function MapList({
{scoreCanBeReported && isMod(user) ? (
-
Report as admin
diff --git a/app/features/team/TeamMemberRepository.server.ts b/app/features/team/TeamMemberRepository.server.ts
new file mode 100644
index 000000000..db03d403a
--- /dev/null
+++ b/app/features/team/TeamMemberRepository.server.ts
@@ -0,0 +1,14 @@
+import { db } from "~/db/sql";
+import type { TablesUpdatable } from "~/db/tables";
+
+export function update(
+ where: { teamId: number; userId: number },
+ values: TablesUpdatable["TeamMember"],
+) {
+ return db
+ .updateTable("AllTeamMember")
+ .set(values)
+ .where("AllTeamMember.teamId", "=", where.teamId)
+ .where("AllTeamMember.userId", "=", where.userId)
+ .execute();
+}
diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts
index 32af27f8a..dc05ae934 100644
--- a/app/features/team/TeamRepository.server.ts
+++ b/app/features/team/TeamRepository.server.ts
@@ -51,7 +51,10 @@ export type findByCustomUrl = NonNullable<
Awaited>
>;
-export function findByCustomUrl(customUrl: string) {
+export function findByCustomUrl(
+ customUrl: string,
+ { includeInviteCode = false } = {},
+) {
return db
.selectFrom("Team")
.leftJoin(
@@ -81,6 +84,7 @@ export function findByCustomUrl(customUrl: string) {
...COMMON_USER_FIELDS,
"TeamMemberWithSecondary.role",
"TeamMemberWithSecondary.isOwner",
+ "TeamMemberWithSecondary.isManager",
"TeamMemberWithSecondary.isMainTeam",
"User.country",
"User.patronTier",
@@ -94,6 +98,7 @@ export function findByCustomUrl(customUrl: string) {
.whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id"),
).as("members"),
])
+ .$if(includeInviteCode, (qb) => qb.select("Team.inviteCode"))
.where("Team.customUrl", "=", customUrl.toLowerCase())
.executeTakeFirst();
}
@@ -245,6 +250,16 @@ export function del(teamId: number) {
});
}
+export function resetInviteCode(teamId: number) {
+ return db
+ .updateTable("AllTeam")
+ .set({
+ inviteCode: nanoid(INVITE_CODE_LENGTH),
+ })
+ .where("id", "=", teamId)
+ .execute();
+}
+
export function addNewTeamMember({
userId,
teamId,
@@ -276,19 +291,24 @@ export function addNewTeamMember({
});
}
-export function removeTeamMember({
+export function handleMemberLeaving({
userId,
teamId,
+ newOwnerUserId,
}: {
userId: number;
teamId: number;
+ newOwnerUserId?: 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, "Owner cannot leave the 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);
@@ -308,9 +328,22 @@ export function removeTeamMember({
.set({
leftAt: databaseTimestampNow(),
isMainTeam: 0,
+ isOwner: 0,
+ isManager: 0,
})
.where("userId", "=", userId)
.where("teamId", "=", teamId)
.execute();
+ if (newOwnerUserId) {
+ await trx
+ .updateTable("AllTeamMember")
+ .set({
+ isOwner: 1,
+ isManager: 0,
+ })
+ .where("userId", "=", newOwnerUserId)
+ .where("teamId", "=", teamId)
+ .execute();
+ }
});
}
diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts
new file mode 100644
index 000000000..08ab099db
--- /dev/null
+++ b/app/features/team/actions/t.$customUrl.edit.server.ts
@@ -0,0 +1,68 @@
+import type { ActionFunction } from "@remix-run/node";
+import { redirect } from "@remix-run/node";
+import { requireUserId } from "~/features/auth/core/user.server";
+import { isAdmin } from "~/permissions";
+import {
+ notFoundIfFalsy,
+ parseRequestPayload,
+ validate,
+} from "~/utils/remix.server";
+import { assertUnreachable } from "~/utils/types";
+import { TEAM_SEARCH_PAGE, mySlugify, teamPage } from "~/utils/urls";
+import * as TeamRepository from "../TeamRepository.server";
+import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
+import { isTeamManager, isTeamOwner } from "../team-utils";
+
+export const action: ActionFunction = async ({ request, params }) => {
+ const user = await requireUserId(request);
+ const { customUrl } = teamParamsSchema.parse(params);
+
+ const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
+
+ validate(
+ isTeamManager({ team, user }) || isAdmin(user),
+ "You are not a team manager",
+ );
+
+ const data = await parseRequestPayload({
+ request,
+ schema: editTeamSchema,
+ });
+
+ switch (data._action) {
+ case "DELETE": {
+ validate(isTeamOwner({ team, user }), "You are not the team owner");
+
+ await TeamRepository.del(team.id);
+
+ throw redirect(TEAM_SEARCH_PAGE);
+ }
+ case "EDIT": {
+ const newCustomUrl = mySlugify(data.name);
+ const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
+
+ validate(
+ newCustomUrl.length > 0,
+ "Team name can't be only special characters",
+ );
+
+ // can't take someone else's custom url
+ if (existingTeam && existingTeam.id !== team.id) {
+ return {
+ errors: ["forms.errors.duplicateName"],
+ };
+ }
+
+ const editedTeam = await TeamRepository.update({
+ id: team.id,
+ customUrl: newCustomUrl,
+ ...data,
+ });
+
+ throw redirect(teamPage(editedTeam.customUrl));
+ }
+ default: {
+ assertUnreachable(data);
+ }
+ }
+};
diff --git a/app/features/team/actions/t.$customUrl.join.server.ts b/app/features/team/actions/t.$customUrl.join.server.ts
new file mode 100644
index 000000000..fd7d6b316
--- /dev/null
+++ b/app/features/team/actions/t.$customUrl.join.server.ts
@@ -0,0 +1,44 @@
+import { type ActionFunction, redirect } from "@remix-run/node";
+import { requireUser } from "~/features/auth/core/user.server";
+import { notFoundIfFalsy, validate } from "~/utils/remix.server";
+import { teamPage } from "~/utils/urls";
+import * as TeamRepository from "../TeamRepository.server";
+import { validateInviteCode } from "../loaders/t.$customUrl.join.server";
+import { TEAM } from "../team-constants";
+import { teamParamsSchema } from "../team-schemas.server";
+
+export const action: ActionFunction = async ({ request, params }) => {
+ const user = await requireUser(request);
+ const { customUrl } = teamParamsSchema.parse(params);
+
+ const team = notFoundIfFalsy(
+ await TeamRepository.findByCustomUrl(customUrl, {
+ includeInviteCode: true,
+ }),
+ );
+
+ const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
+ const realInviteCode = team.inviteCode!;
+
+ validate(
+ validateInviteCode({
+ inviteCode,
+ realInviteCode,
+ team,
+ user,
+ reachedTeamCountLimit: false, // checked in the DB transaction
+ }) === "VALID",
+ "Invite code is invalid",
+ );
+
+ await TeamRepository.addNewTeamMember({
+ maxTeamsAllowed:
+ user.patronTier && user.patronTier >= 2
+ ? TEAM.MAX_TEAM_COUNT_PATRON
+ : TEAM.MAX_TEAM_COUNT_NON_PATRON,
+ teamId: team.id,
+ userId: user.id,
+ });
+
+ throw redirect(teamPage(team.customUrl));
+};
diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts
new file mode 100644
index 000000000..f331b1830
--- /dev/null
+++ b/app/features/team/actions/t.$customUrl.roster.server.ts
@@ -0,0 +1,89 @@
+import type { ActionFunction } from "@remix-run/node";
+import { requireUserId } from "~/features/auth/core/user.server";
+import { isAdmin } from "~/permissions";
+import {
+ notFoundIfFalsy,
+ parseRequestPayload,
+ validate,
+} from "~/utils/remix.server";
+import { assertUnreachable } from "~/utils/types";
+import * as TeamMemberRepository from "../TeamMemberRepository.server";
+import * as TeamRepository from "../TeamRepository.server";
+import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
+import { isTeamManager } from "../team-utils";
+
+export const action: ActionFunction = async ({ request, params }) => {
+ const user = await requireUserId(request);
+
+ const { customUrl } = teamParamsSchema.parse(params);
+ const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
+ validate(
+ isTeamManager({ team, user }) || isAdmin(user),
+ "Only team manager or owner can manage roster",
+ );
+
+ const data = await parseRequestPayload({
+ request,
+ schema: manageRosterSchema,
+ });
+
+ switch (data._action) {
+ case "DELETE_MEMBER": {
+ const member = team.members.find((m) => m.id === data.userId);
+
+ validate(member, "Member not found");
+ validate(member.id !== user.id, "Can't delete yourself");
+ validate(!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,
+ },
+ );
+
+ break;
+ }
+ case "REMOVE_MANAGER": {
+ const member = team.members.find((m) => m.id === data.userId);
+ validate(member, "Member not found");
+ validate(member.id !== user.id, "Can't remove yourself as manager");
+
+ await TeamMemberRepository.update(
+ { teamId: team.id, userId: data.userId },
+ {
+ isManager: 0,
+ },
+ );
+
+ break;
+ }
+ case "UPDATE_MEMBER_ROLE": {
+ await TeamMemberRepository.update(
+ { teamId: team.id, userId: data.userId },
+ {
+ role: data.role || null,
+ },
+ );
+
+ break;
+ }
+ default: {
+ assertUnreachable(data);
+ }
+ }
+
+ return null;
+};
diff --git a/app/features/team/actions/t.$customUrl.server.ts b/app/features/team/actions/t.$customUrl.server.ts
index 7d23e96e7..ac35e00ea 100644
--- a/app/features/team/actions/t.$customUrl.server.ts
+++ b/app/features/team/actions/t.$customUrl.server.ts
@@ -11,7 +11,7 @@ import {
teamParamsSchema,
teamProfilePageActionSchema,
} from "../team-schemas.server";
-import { isTeamMember, isTeamOwner } from "../team-utils";
+import { isTeamMember, isTeamOwner, resolveNewOwner } from "../team-utils";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
@@ -26,13 +26,22 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "LEAVE_TEAM": {
validate(
- isTeamMember({ user, team }) && !isTeamOwner({ user, team }),
- "You are not a regular member of this team",
+ isTeamMember({ user, team }),
+ "You are not a member of this team",
);
- await TeamRepository.removeTeamMember({
+ const newOwner = isTeamOwner({ user, team })
+ ? resolveNewOwner(team.members)
+ : null;
+ validate(
+ !isTeamOwner({ user, team }) || newOwner,
+ "You can't leave the team if you are the owner and there is no other member to become the owner",
+ );
+
+ await TeamRepository.handleMemberLeaving({
teamId: team.id,
userId: user.id,
+ newOwnerUserId: newOwner?.id,
});
break;
diff --git a/app/features/team/index.ts b/app/features/team/index.ts
deleted file mode 100644
index ecac30131..000000000
--- a/app/features/team/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export { TEAM_MEMBER_ROLES } from "./team-constants";
-
-export { isTeamOwner } from "./team-utils";
diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts
new file mode 100644
index 000000000..085bdef69
--- /dev/null
+++ b/app/features/team/loaders/t.$customUrl.edit.server.ts
@@ -0,0 +1,22 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { redirect } from "@remix-run/node";
+import { requireUserId } from "~/features/auth/core/user.server";
+import { isAdmin } from "~/permissions";
+import { notFoundIfFalsy } from "~/utils/remix.server";
+import { teamPage } from "~/utils/urls";
+import * as TeamRepository from "../TeamRepository.server";
+import { teamParamsSchema } from "../team-schemas.server";
+import { isTeamManager } from "../team-utils";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUserId(request);
+ const { customUrl } = teamParamsSchema.parse(params);
+
+ const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
+
+ if (!isTeamManager({ team, user }) && !isAdmin(user)) {
+ throw redirect(teamPage(customUrl));
+ }
+
+ return { team, css: team.css };
+};
diff --git a/app/features/team/loaders/t.$customUrl.join.server.ts b/app/features/team/loaders/t.$customUrl.join.server.ts
new file mode 100644
index 000000000..37909b7ba
--- /dev/null
+++ b/app/features/team/loaders/t.$customUrl.join.server.ts
@@ -0,0 +1,78 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { redirect } from "@remix-run/node";
+import { INVITE_CODE_LENGTH } from "~/constants";
+import { requireUser } from "~/features/auth/core/user.server";
+import { notFoundIfFalsy } from "~/utils/remix.server";
+import { teamPage } from "~/utils/urls";
+import * as TeamRepository from "../TeamRepository.server";
+import { TEAM } from "../team-constants";
+import { teamParamsSchema } from "../team-schemas.server";
+import { isTeamFull, isTeamMember } from "../team-utils";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUser(request);
+ const { customUrl } = teamParamsSchema.parse(params);
+
+ const team = notFoundIfFalsy(
+ await TeamRepository.findByCustomUrl(customUrl, {
+ includeInviteCode: true,
+ }),
+ );
+
+ const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
+ const realInviteCode = team.inviteCode!;
+
+ const teamCount = (await TeamRepository.teamsByMemberUserId(user.id)).length;
+
+ const validation = validateInviteCode({
+ inviteCode,
+ realInviteCode,
+ team,
+ user,
+ reachedTeamCountLimit:
+ user.patronTier && user.patronTier >= 2
+ ? teamCount >= TEAM.MAX_TEAM_COUNT_PATRON
+ : teamCount >= TEAM.MAX_TEAM_COUNT_NON_PATRON,
+ });
+
+ if (validation === "ALREADY_JOINED") {
+ throw redirect(teamPage(team.customUrl));
+ }
+
+ return {
+ validation,
+ teamName: team.name,
+ };
+};
+
+export function validateInviteCode({
+ inviteCode,
+ realInviteCode,
+ team,
+ user,
+ reachedTeamCountLimit,
+}: {
+ inviteCode: string;
+ realInviteCode: string;
+ team: TeamRepository.findByCustomUrl;
+ user?: { id: number; team?: { name: string } };
+ reachedTeamCountLimit: boolean;
+}) {
+ if (inviteCode.length !== INVITE_CODE_LENGTH) {
+ return "SHORT_CODE";
+ }
+ if (inviteCode !== realInviteCode) {
+ return "INVITE_CODE_WRONG";
+ }
+ if (isTeamFull(team)) {
+ return "TEAM_FULL";
+ }
+ if (isTeamMember({ team, user })) {
+ return "ALREADY_JOINED";
+ }
+ if (reachedTeamCountLimit) {
+ return "REACHED_TEAM_COUNT_LIMIT";
+ }
+
+ return "VALID";
+}
diff --git a/app/features/team/loaders/t.$customUrl.roster.server.ts b/app/features/team/loaders/t.$customUrl.roster.server.ts
new file mode 100644
index 000000000..f2173d542
--- /dev/null
+++ b/app/features/team/loaders/t.$customUrl.roster.server.ts
@@ -0,0 +1,29 @@
+import type { LoaderFunctionArgs } from "@remix-run/node";
+import { redirect } from "@remix-run/node";
+import { requireUserId } from "~/features/auth/core/user.server";
+import { isAdmin } from "~/permissions";
+import { notFoundIfFalsy } from "~/utils/remix.server";
+import { teamPage } from "~/utils/urls";
+import * as TeamRepository from "../TeamRepository.server";
+import { teamParamsSchema } from "../team-schemas.server";
+import { isTeamManager } from "../team-utils";
+import "../team.css";
+
+export const loader = async ({ request, params }: LoaderFunctionArgs) => {
+ const user = await requireUserId(request);
+ const { customUrl } = teamParamsSchema.parse(params);
+
+ const team = notFoundIfFalsy(
+ await TeamRepository.findByCustomUrl(customUrl, {
+ includeInviteCode: true,
+ }),
+ );
+
+ if (!isTeamManager({ team, user }) && !isAdmin(user)) {
+ throw redirect(teamPage(customUrl));
+ }
+
+ return {
+ team,
+ };
+};
diff --git a/app/features/team/queries/editRole.server.ts b/app/features/team/queries/editRole.server.ts
deleted file mode 100644
index ace608633..000000000
--- a/app/features/team/queries/editRole.server.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-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 });
-}
diff --git a/app/features/team/queries/inviteCodeById.server.ts b/app/features/team/queries/inviteCodeById.server.ts
deleted file mode 100644
index b3fcaaebe..000000000
--- a/app/features/team/queries/inviteCodeById.server.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-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 }) as any)?.inviteCode ?? null;
-}
diff --git a/app/features/team/queries/resetInviteLink.server.ts b/app/features/team/queries/resetInviteLink.server.ts
deleted file mode 100644
index eb8dd7050..000000000
--- a/app/features/team/queries/resetInviteLink.server.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-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) });
-}
diff --git a/app/features/team/queries/transferOwnership.server.ts b/app/features/team/queries/transferOwnership.server.ts
deleted file mode 100644
index a19f82307..000000000
--- a/app/features/team/queries/transferOwnership.server.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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 });
- },
-);
diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx
index f26a2e267..e45496d17 100644
--- a/app/features/team/routes/t.$customUrl.edit.tsx
+++ b/app/features/team/routes/t.$customUrl.edit.tsx
@@ -1,10 +1,4 @@
-import type {
- ActionFunction,
- LoaderFunctionArgs,
- MetaFunction,
- SerializeFrom,
-} from "@remix-run/node";
-import { redirect } from "@remix-run/node";
+import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Form, Link, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -17,30 +11,23 @@ import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
-import { requireUserId } from "~/features/auth/core/user.server";
-import { isAdmin } from "~/permissions";
-import {
- type SendouRouteHandle,
- notFoundIfFalsy,
- parseRequestPayload,
- validate,
-} from "~/utils/remix.server";
+import { useUser } from "~/features/auth/core/user";
+import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
-import { assertUnreachable } from "~/utils/types";
import {
TEAM_SEARCH_PAGE,
- mySlugify,
navIconUrl,
teamPage,
uploadImagePage,
} from "~/utils/urls";
-import * as TeamRepository from "../TeamRepository.server";
+import { action } from "../actions/t.$customUrl.edit.server";
+import { loader } from "../loaders/t.$customUrl.edit.server";
import { TEAM } from "../team-constants";
-import { editTeamSchema, teamParamsSchema } from "../team-schemas.server";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
-
import "../team.css";
+export { action, loader };
+
export const meta: MetaFunction = ({ data }) => {
if (!data) return [];
@@ -69,89 +56,27 @@ export const handle: SendouRouteHandle = {
},
};
-export const action: ActionFunction = async ({ request, params }) => {
- const user = await requireUserId(request);
- const { customUrl } = teamParamsSchema.parse(params);
-
- const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
-
- validate(
- isTeamOwner({ team, user }) || isAdmin(user),
- "You are not the team owner",
- );
-
- const data = await parseRequestPayload({
- request,
- schema: editTeamSchema,
- });
-
- switch (data._action) {
- case "DELETE": {
- await TeamRepository.del(team.id);
-
- throw redirect(TEAM_SEARCH_PAGE);
- }
- case "EDIT": {
- const newCustomUrl = mySlugify(data.name);
- const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
-
- validate(
- newCustomUrl.length > 0,
- "Team name can't be only special characters",
- );
-
- // can't take someone else's custom url
- if (existingTeam && existingTeam.id !== team.id) {
- return {
- errors: ["forms.errors.duplicateName"],
- };
- }
-
- const editedTeam = await TeamRepository.update({
- id: team.id,
- customUrl: newCustomUrl,
- ...data,
- });
-
- throw redirect(teamPage(editedTeam.customUrl));
- }
- default: {
- assertUnreachable(data);
- }
- }
-};
-
-export const loader = async ({ request, params }: LoaderFunctionArgs) => {
- const user = await requireUserId(request);
- const { customUrl } = teamParamsSchema.parse(params);
-
- const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
-
- if (!isTeamOwner({ team, user }) && !isAdmin(user)) {
- throw redirect(teamPage(customUrl));
- }
-
- return { team, css: team.css };
-};
-
export default function EditTeamPage() {
const { t } = useTranslation(["common", "team"]);
+ const user = useUser();
const { team, css } = useLoaderData();
return (
-
-
-
+
+
+ ) : null}
+ }
+ >
+ {t("team:editorsInfo.popover")}
+
);
}
@@ -163,7 +98,7 @@ function InviteCodeSection() {
const inviteLink = `${import.meta.env.VITE_SITE_DOMAIN}${joinTeamPage({
customUrl: team.customUrl,
- inviteCode: team.inviteCode,
+ inviteCode: team.inviteCode!,
})}`;
return (
@@ -219,11 +154,25 @@ function MemberRow({
const { team } = useLoaderData();
const { t } = useTranslation(["team"]);
const user = useUser();
+
const roleFetcher = useFetcher();
+ const editorFetcher = useFetcher();
const isSelf = user!.id === member.id;
const role = team.members.find((m) => m.id === member.id)?.role ?? NO_ROLE;
+ const isThisMemberOwner = Boolean(
+ team.members.find((m) => m.id === member.id)?.isOwner,
+ );
+ const isThisMemberManager = Boolean(
+ team.members.find((m) => m.id === member.id)?.isManager,
+ );
+
+ const editorIsBeingAdded =
+ editorFetcher.formData?.get("_action") === "ADD_MANAGER";
+ const editorIsBeingRemoved =
+ editorFetcher.formData?.get("_action") === "REMOVE_MANAGER";
+
return (
-
+
+
+ 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")}
+
+
+
}
testId={!isSelf ? "kick-button" : undefined}
>
{t("team:actionButtons.kick")}
-
-
-
-
-
);
diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx
index 77122f869..5a466ccea 100644
--- a/app/features/team/routes/t.$customUrl.tsx
+++ b/app/features/team/routes/t.$customUrl.tsx
@@ -30,13 +30,11 @@ import {
userSubmittedImage,
} from "~/utils/urls";
import type * as TeamRepository from "../TeamRepository.server";
-import { isTeamMember, isTeamOwner } from "../team-utils";
-
import { action } from "../actions/t.$customUrl.server";
import { loader } from "../loaders/t.$customUrl.server";
-export { action, loader };
-
+import { isTeamManager, isTeamMember, resolveNewOwner } from "../team-utils";
import "../team.css";
+export { action, loader };
export const meta: MetaFunction
= ({ data }) => {
if (!data) return [];
@@ -192,9 +190,12 @@ function ActionButtons() {
{isTeamMember({ user, team }) && !isMainTeam ? (
) : null}
- {!isTeamOwner({ user, team }) && isTeamMember({ user, team }) ? (
+ {isTeamMember({ user, team }) ? (
@@ -207,7 +208,7 @@ function ActionButtons() {
) : null}
- {isTeamOwner({ user, team }) || isAdmin(user) ? (
+ {isTeamManager({ user, team }) || isAdmin(user) ? (
) : null}
- {isTeamOwner({ user, team }) || isAdmin(user) ? (
+ {isTeamManager({ user, team }) || isAdmin(user) ? (
member.isOwner && member.id === user.id);
}
+export function isTeamManager({
+ team,
+ user,
+}: {
+ team: TeamRepository.findByCustomUrl;
+ user?: { id: number };
+}) {
+ if (!user) return false;
+
+ return team.members.some(
+ (member) => (member.isManager || member.isOwner) && member.id === user.id,
+ );
+}
+
export function isTeamMember({
team,
user,
@@ -36,3 +50,25 @@ export function canAddCustomizedColors(team: {
(member) => member.patronTier && member.patronTier >= 2,
);
}
+
+/** Returns the user who will become the new owner after old one leaves */
+export function resolveNewOwner(
+ members: Array<{
+ id: number;
+ username: string;
+ isOwner: number;
+ isManager: number;
+ }>,
+) {
+ const managers = members.filter((m) => m.isManager && !m.isOwner);
+ if (managers.length > 0) {
+ return managers.sort((a, b) => a.id - b.id)[0];
+ }
+
+ const regularMembers = members.filter((m) => !m.isOwner);
+ if (regularMembers.length > 0) {
+ return regularMembers.sort((a, b) => a.id - b.id)[0];
+ }
+
+ return null;
+}
diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx
index 9e618c489..7440191db 100644
--- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx
+++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx
@@ -7,7 +7,7 @@ import { Dialog } from "~/components/Dialog";
import { ModeImage, StageImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
-import { Toggle } from "~/components/Toggle";
+import { SendouSwitch } from "~/components/elements/Switch";
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
import type { TournamentRoundMaps } from "~/db/tables";
import {
@@ -683,6 +683,7 @@ function PickBanSelect({
Pick/ban