Team editors in addition to the owner (#2077)

* Initial

* Handle owner leaving

* Remove old team queries

* Progress

* Retire old toggle

* e2e tests

* Divide loaders/actions of team pages
This commit is contained in:
Kalle 2025-02-04 10:56:33 +02:00 committed by GitHub
parent 29e0200b6d
commit 8f156fb917
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 842 additions and 657 deletions

View File

@ -1,32 +0,0 @@
import { Switch } from "@headlessui/react";
import clsx from "clsx";
export function Toggle({
checked,
setChecked,
tiny,
id,
name,
disabled,
}: {
checked: boolean;
setChecked: (checked: boolean) => void;
tiny?: boolean;
id?: string;
name?: string;
disabled?: boolean;
}) {
return (
<Switch
checked={checked}
onChange={setChecked}
className={clsx("toggle", { checked, tiny })}
id={id}
name={name}
data-testid={id ? `toggle-${id}` : null}
disabled={disabled}
>
<span className={clsx("toggle-dot", { checked, tiny })} />
</Switch>
);
}

View File

@ -14,7 +14,7 @@ interface MyDatePickerProps extends ReactAriaButtonProps {
| "minimal"
| "minimal-success"
| "minimal-destructive";
size?: "miniscule" | "tiny" | "big";
size?: "miniscule" | "small" | "medium" | "big";
icon?: JSX.Element;
children?: React.ReactNode;
}
@ -34,7 +34,7 @@ export function SendouButton({
"react-aria-Button",
variant,
{
tiny: size === "tiny",
small: size === "small",
big: size === "big",
miniscule: size === "miniscule",
},

View File

@ -21,7 +21,7 @@ export function SendouPopover({
<DialogTrigger>
{trigger}
<Popover
className={clsx("popover-content", popoverClassName)}
className={clsx("sendou-popover-content", popoverClassName)}
placement={placement}
>
<Dialog>{children}</Dialog>

View File

@ -0,0 +1,22 @@
import clsx from "clsx";
import {
Switch as ReactAriaSwitch,
type SwitchProps as ReactAriaSwitchProps,
} from "react-aria-components";
interface SendouSwitchProps extends ReactAriaSwitchProps {
children?: React.ReactNode;
size?: "small" | "medium";
}
export function SendouSwitch({ children, size, ...rest }: SendouSwitchProps) {
return (
<ReactAriaSwitch
{...rest}
className={clsx("react-aria-Switch", { small: size === "small" })}
>
<div className="indicator" />
{children}
</ReactAriaSwitch>
);
}

View File

@ -8,7 +8,7 @@ import {
} from "react-hook-form";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Toggle } from "../Toggle";
import { SendouSwitch } from "../elements/Switch";
export function ToggleFormField<T extends FieldValues>({
label,
@ -27,7 +27,7 @@ export function ToggleFormField<T extends FieldValues>({
control={methods.control}
name={name}
render={({ field: { value, onChange } }) => (
<Toggle checked={value} setChecked={onChange} />
<SendouSwitch id={id} isSelected={value} onChange={onChange} />
)}
/>
{error && (

View File

@ -158,7 +158,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
tournamentSubs,
adminBuilds,
manySplattershotBuilds,
detailedTeam,
detailedTeam(variation),
otherTeams,
realVideo,
realVideoCast,
@ -1560,7 +1560,7 @@ async function manySplattershotBuilds() {
}
}
function detailedTeam() {
const detailedTeam = (seedVariation?: SeedVariation | null) => () => {
sql
.prepare(
/* sql */ `
@ -1591,6 +1591,9 @@ function detailedTeam() {
const userIds = userIdsInRandomOrder(true).filter(
(id) => id !== NZAP_TEST_ID,
);
if (seedVariation === "NZAP_IN_TEAM") {
userIds.unshift(NZAP_TEST_ID);
}
for (let i = 0; i < 5; i++) {
const userId = i === 0 ? ADMIN_ID : userIds.shift()!;
@ -1609,7 +1612,7 @@ function detailedTeam() {
)
.run();
}
}
};
function otherTeams() {
const usersInTeam = (

View File

@ -4,9 +4,10 @@ import type {
Insertable,
Selectable,
SqlBool,
Updateable,
} from "kysely";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { TEAM_MEMBER_ROLES } from "~/features/team";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { ParticipantResult } from "~/modules/brackets-model";
import type {
@ -40,6 +41,7 @@ export interface Team {
export interface TeamMember {
createdAt: Generated<number>;
isOwner: Generated<number>;
isManager: Generated<number>;
leftAt: number | null;
role: MemberRole | null;
teamId: number;
@ -871,6 +873,7 @@ export interface XRankPlacement {
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

@ -3,7 +3,7 @@ import type {
tags,
} from "~/features/calendar/calendar-constants";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import type { TEAM_MEMBER_ROLES } from "~/features/team";
import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type {
Ability,
MainWeaponId,

View File

@ -5,7 +5,13 @@ import { parseRequestPayload } from "~/utils/remix.server";
const seedSchema = z.object({
variation: z
.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT", "REG_OPEN", "SMALL_SOS"])
.enum([
"NO_TOURNAMENT_TEAMS",
"DEFAULT",
"REG_OPEN",
"SMALL_SOS",
"NZAP_IN_TEAM",
])
.nullish(),
});

View File

@ -18,8 +18,8 @@ import { Combobox } from "~/components/Combobox";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { UserSearch } from "~/components/UserSearch";
import { SendouSwitch } from "~/components/elements/Switch";
import { CrossIcon } from "~/components/icons/Cross";
import { useUser } from "~/features/auth/core/user";
import { requireUser } from "~/features/auth/core/user.server";
@ -481,11 +481,12 @@ function ShowcaseToggle() {
return (
<div>
<label htmlFor="isShowcase">{t("art:forms.showcase.title")}</label>
<Toggle
checked={checked}
setChecked={setChecked}
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="isShowcase"
disabled={isCurrentlyShowcase}
id="isShowcase"
isDisabled={isCurrentlyShowcase}
/>
<FormMessage type="info">{t("art:forms.showcase.info")}</FormMessage>
</div>

View File

@ -10,7 +10,7 @@ import { Button } from "~/components/Button";
import { Combobox } from "~/components/Combobox";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import { SendouSwitch } from "~/components/elements/Switch";
import { CrossIcon } from "~/components/icons/Cross";
import i18next from "~/modules/i18n/i18next.server";
import type { SendouRouteHandle } from "~/utils/remix.server";
@ -85,9 +85,9 @@ export default function ArtPage() {
<Main className="stack lg">
<div className="stack horizontal md justify-between items-center flex-wrap">
<div className="stack horizontal sm text-sm font-semi-bold">
<Toggle
checked={showOpenCommissions}
setChecked={() =>
<SendouSwitch
isSelected={showOpenCommissions}
onChange={() =>
setSearchParams((prev) => {
prev.set(OPEN_COMMISIONS_KEY, String(!showOpenCommissions));
return prev;

View File

@ -21,7 +21,7 @@ export function PerInkTankGrid(props: PerInkTankGridProps) {
<SendouPopover
popoverClassName="analyzer__ink-grid__container"
trigger={
<SendouButton variant="minimal" size="tiny">
<SendouButton variant="minimal" size="small">
{t("analyzer:button.showConsumptionGrid")}
</SendouButton>
}

View File

@ -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({
})}
</select>
) : (
<Toggle
checked={effects.includes(effect.type)}
setChecked={(checked) =>
checked
<SendouSwitch
isSelected={effects.includes(effect.type)}
onChange={(isSelected) =>
isSelected
? handleAddEffect(effect.type)
: handleRemoveEffect(effect.type)
}
tiny
size="small"
/>
)}
</div>

View File

@ -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 ? (
<div>
<Label htmlFor={createId("checkIn")}>Check-in required</Label>
<Toggle
checked={bracket.requiresCheckIn}
setChecked={(checked) =>
updateBracket({ requiresCheckIn: checked })
<SendouSwitch
id={createId("checkIn")}
isSelected={bracket.requiresCheckIn}
onChange={(isSelected) =>
updateBracket({ requiresCheckIn: isSelected })
}
disabled={bracket.disabled}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Check-in starts 1 hour before start time or right after the
@ -247,14 +248,18 @@ function TournamentFormatBracketSelector({
<Label htmlFor={createId("thirdPlaceMatch")}>
Third place match
</Label>
<Toggle
checked={Boolean(bracket.settings.thirdPlaceMatch)}
setChecked={(checked) =>
<SendouSwitch
id={createId("thirdPlaceMatch")}
isSelected={Boolean(bracket.settings.thirdPlaceMatch)}
onChange={(isSelected) =>
updateBracket({
settings: { ...bracket.settings, thirdPlaceMatch: checked },
settings: {
...bracket.settings,
thirdPlaceMatch: isSelected,
},
})
}
disabled={bracket.disabled}
isDisabled={bracket.disabled}
/>
</div>
) : null}
@ -347,18 +352,19 @@ function TournamentFormatBracketSelector({
</div>
{!isFirstBracket ? (
<div className="stack sm horizontal mt-1 mb-2">
<Toggle
<SendouSwitch
id={createId("follow-up-bracket")}
tiny
checked={Boolean(bracket.sources)}
setChecked={(checked) =>
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"
/>
<Label htmlFor={createId("follow-up-bracket")} spaced={false}>
Is follow-up bracket

View File

@ -17,7 +17,6 @@ import { Main } from "~/components/Main";
import { MapPoolSelector } from "~/components/MapPoolSelector";
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { CrossIcon } from "~/components/icons/Cross";
import { TrashIcon } from "~/components/icons/Trash";
import type { Tables } from "~/db/tables";
@ -51,10 +50,9 @@ import {
import { canAddNewEvent } from "../calendar-utils";
import { BracketProgressionSelector } from "../components/BracketProgressionSelector";
import { Tags } from "../components/Tags";
import "~/styles/calendar-new.css";
import "~/styles/maps.css";
import { SendouSwitch } from "~/components/elements/Switch";
import { action } from "../actions/calendar.new.server";
import { loader } from "../loaders/calendar.new.server";
export { loader, action };
@ -808,12 +806,12 @@ function RankedToggle() {
<label htmlFor={id} className="w-max">
Ranked
</label>
<Toggle
<SendouSwitch
name="isRanked"
id={id}
tiny
checked={isRanked}
setChecked={setIsRanked}
size="small"
isSelected={isRanked}
onChange={setIsRanked}
/>
<FormMessage type="info">
Ranked tournaments affect SP. Tournaments that don&apos;t have open
@ -837,12 +835,12 @@ function EnableNoScreenToggle() {
<label htmlFor={id} className="w-max">
Splattercolor Screen toggle
</label>
<Toggle
<SendouSwitch
name="enableNoScreenToggle"
id={id}
tiny
checked={enableNoScreen}
setChecked={setEnableNoScreen}
size="small"
isSelected={enableNoScreen}
onChange={setEnableNoScreen}
/>
<FormMessage type="info">
When registering ask teams if they want to play without Splattercolor
@ -864,12 +862,12 @@ function EnableSubsToggle() {
<label htmlFor={id} className="w-max">
Subs tab
</label>
<Toggle
<SendouSwitch
name="enableSubs"
id={id}
tiny
checked={enableSubs}
setChecked={setEnableSubs}
size="small"
isSelected={enableSubs}
onChange={setEnableSubs}
/>
<FormMessage type="info">
Allow users to sign up as "subs" in addition to the normal event
@ -891,12 +889,12 @@ function AutonomousSubsToggle() {
<label htmlFor={id} className="w-max">
Autonomous subs
</label>
<Toggle
<SendouSwitch
name="autonomousSubs"
id={id}
tiny
checked={autonomousSubs}
setChecked={setAutonomousSubs}
size="small"
isSelected={autonomousSubs}
onChange={setAutonomousSubs}
/>
<FormMessage type="info">
If enabled teams can add subs on their own while the tournament is in
@ -918,12 +916,12 @@ function RequireIGNToggle() {
<label htmlFor={id} className="w-max">
Require in-game names
</label>
<Toggle
<SendouSwitch
name="requireInGameNames"
id={id}
tiny
checked={requireIGNs}
setChecked={setRequireIGNs}
size="small"
isSelected={requireIGNs}
onChange={setRequireIGNs}
/>
<FormMessage type="info">
If enabled players can&apos;t join the tournament without an in-game
@ -948,12 +946,12 @@ function InvitationalToggle({
<label htmlFor={id} className="w-max">
Invitational
</label>
<Toggle
<SendouSwitch
name="isInvitational"
id={id}
tiny
checked={isInvitational}
setChecked={setIsInvitational}
size="small"
isSelected={isInvitational}
onChange={setIsInvitational}
/>
<FormMessage type="info">
No open registration or subs list. All teams must be added by the
@ -975,12 +973,12 @@ function StrictDeadlinesToggle() {
<label htmlFor={id} className="w-max">
Strict deadlines
</label>
<Toggle
<SendouSwitch
name="strictDeadline"
id={id}
tiny
checked={strictDeadlines}
setChecked={setStrictDeadlines}
size="small"
isSelected={strictDeadlines}
onChange={setStrictDeadlines}
/>
<FormMessage type="info">
Strict deadlines has 5 minutes less for the target time of each round

View File

@ -45,7 +45,6 @@ import {
} from "~/utils/urls";
import { actualNumber, safeSplit } from "~/utils/zod";
import { Label } from "../../../components/Label";
import { Toggle } from "../../../components/Toggle";
import type {
CalendarEventTag,
PersistedCalendarEventTag,
@ -55,8 +54,8 @@ import { calendarEventTagSchema } from "../actions/calendar.new.server";
import { CALENDAR_EVENT } from "../calendar-constants";
import { closeByWeeks } from "../calendar-utils";
import { Tags } from "../components/Tags";
import "~/styles/calendar.css";
import { SendouSwitch } from "~/components/elements/Switch";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
@ -463,10 +462,10 @@ function OnSendouInkToggle() {
<Label htmlFor="onlyTournaments">
{t("calendar:tournament.filter.label")}
</Label>
<Toggle
<SendouSwitch
id="onlyTournaments"
checked={onlyTournaments}
setChecked={setOnlyTournaments}
isSelected={onlyTournaments}
onChange={setOnlyTournaments}
/>
</div>
</div>

View File

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

View File

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

View File

@ -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 }) {
<div className="maps__map-list-creator">
<div className="maps__toggle-container">
<Label>{t("common:maps.halfSz")}</Label>
<Toggle checked={szEveryOther} setChecked={setSzEveryOther} tiny />
<SendouSwitch
isSelected={szEveryOther}
onChange={setSzEveryOther}
size="small"
/>
</div>
<Button onClick={handleCreateMaplist} disabled={disabled}>
{t("common:maps.createMapList")}

View File

@ -7,7 +7,6 @@ import { AllWeaponCombobox } from "~/components/Combobox";
import { Image, WeaponImage } from "~/components/Image";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Toggle } from "~/components/Toggle";
import type { AnyWeapon, DamageType } from "~/features/build-analyzer";
import { possibleApValues } from "~/features/build-analyzer";
import { useSetTitle } from "~/hooks/useSetTitle";
@ -38,8 +37,8 @@ import {
} from "~/utils/urls";
import { useObjectDamage } from "../calculator-hooks";
import type { DamageReceiver } from "../calculator-types";
import "../calculator.css";
import { SendouSwitch } from "~/components/elements/Switch";
export const CURRENT_PATCH = "9.2";
@ -111,13 +110,13 @@ export default function ObjectDamagePage() {
<label className="plain" htmlFor="multi">
×{multiShotCount}
</label>
<Toggle
<SendouSwitch
id="multi"
name="multi"
checked={isMultiShot}
setChecked={(checked) =>
handleChange({ newIsMultiShot: checked })
isSelected={isMultiShot}
onChange={(isSelected) =>
handleChange({ newIsMultiShot: isSelected })
}
data-testid="multi-switch"
/>
</div>
) : null}

View File

@ -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() {
</summary>
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
<div className="stack horizontal xs items-center">
<Toggle
checked={checked}
setChecked={setChecked}
<SendouSwitch
isSelected={checked}
onChange={setChecked}
id="noScreen"
name="noScreen"
/>

View File

@ -817,7 +817,7 @@ function VoiceChatInfo({
trigger={
<SendouButton
variant="minimal"
size="tiny"
size="small"
icon={<Icon className={clsx("q__group-member-vc-icon", color())} />}
/>
}

View File

@ -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<typeof loader> | null;
@ -733,7 +732,7 @@ function DisputePopover() {
popoverClassName="text-main-forced"
trigger={
<SendouButton
size="tiny"
size="small"
variant="minimal-destructive"
className="mt-2"
>
@ -1304,7 +1303,7 @@ function ScreenLegalityInfo({ ban }: { ban: boolean }) {
trigger={
<SendouButton
variant="minimal"
size="tiny"
size="small"
className="q-match__screen-legality__button"
>
<Alert variation={ban ? "ERROR" : "SUCCESS"}>
@ -1437,10 +1436,10 @@ function MapList({
</Flipper>
{scoreCanBeReported && isMod(user) ? (
<div className="stack sm horizontal items-center text-sm font-semi-bold">
<Toggle
<SendouSwitch
name="adminReport"
checked={adminToggleChecked}
setChecked={setAdminToggleChecked}
isSelected={adminToggleChecked}
onChange={setAdminToggleChecked}
/>
Report as admin
</div>

View File

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

View File

@ -51,7 +51,10 @@ export type findByCustomUrl = NonNullable<
Awaited<ReturnType<typeof findByCustomUrl>>
>;
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();
}
});
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export { TEAM_MEMBER_ROLES } from "./team-constants";
export { isTeamOwner } from "./team-utils";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<typeof loader> = ({ data }) => {
if (!data) return [];
@ -69,77 +56,14 @@ 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<typeof loader>();
return (
<Main className="half-width">
{isTeamOwner({ team, user }) ? (
<FormWithConfirm
dialogHeading={t("team:deleteTeam.header", { teamName: team.name })}
fields={[["_action", "DELETE"]]}
@ -152,6 +76,7 @@ export default function EditTeamPage() {
{t("team:actionButtons.deleteTeam")}
</Button>
</FormWithConfirm>
) : null}
<Form method="post" className="stack md items-start">
<ImageUploadLinks />
{canAddCustomizedColors(team) ? (

View File

@ -1,125 +1,18 @@
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { INVITE_CODE_LENGTH } from "~/constants";
import { requireUser } from "~/features/auth/core/user.server";
import {
type SendouRouteHandle,
notFoundIfFalsy,
validate,
} from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { inviteCodeById } from "../queries/inviteCodeById.server";
import { TEAM } from "../team-constants";
import { teamParamsSchema } from "../team-schemas.server";
import { isTeamFull, isTeamMember } from "../team-utils";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { action } from "../actions/t.$customUrl.join.server";
import { loader } from "../loaders/t.$customUrl.join.server";
import "../team.css";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;
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));
};
export { loader, action };
export const handle: SendouRouteHandle = {
i18n: ["team"],
};
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = inviteCodeById(team.id)!;
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,
};
};
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";
}
export default function JoinTeamPage() {
const { t } = useTranslation(["team", "common"]);
const { validation, teamName } = useLoaderData<{

View File

@ -1,10 +1,4 @@
import { redirect } from "@remix-run/node";
import type {
ActionFunction,
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
@ -15,33 +9,27 @@ import { Button } from "~/components/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { SendouSwitch } from "~/components/elements/Switch";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core/user";
import { requireUserId } from "~/features/auth/core/user.server";
import { isAdmin } from "~/permissions";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
TEAM_SEARCH_PAGE,
joinTeamPage,
navIconUrl,
teamPage,
} from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { editRole } from "../queries/editRole.server";
import { inviteCodeById } from "../queries/inviteCodeById.server";
import { resetInviteLink } from "../queries/resetInviteLink.server";
import { transferOwnership } from "../queries/transferOwnership.server";
import type * as TeamRepository from "../TeamRepository.server";
import { TEAM_MEMBER_ROLES } from "../team-constants";
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
import { isTeamFull, isTeamOwner } from "../team-utils";
import { isTeamFull } from "../team-utils";
import "../team.css";
import { action } from "../actions/t.$customUrl.roster.server";
import { loader } from "../loaders/t.$customUrl.roster.server";
export { loader, action };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
@ -49,59 +37,6 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: makeTitle(data.team.name) }];
};
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),
"Only team owner can manage roster",
);
const data = await parseRequestPayload({
request,
schema: manageRosterSchema,
});
switch (data._action) {
case "DELETE_MEMBER": {
validate(data.userId !== user.id, "Can't delete yourself");
await TeamRepository.removeTeamMember({
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,
});
throw 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: ({ match }) => {
@ -124,26 +59,26 @@ export const handle: SendouRouteHandle = {
},
};
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: { ...team, inviteCode: inviteCodeById(team.id)! },
};
};
export default function ManageTeamRosterPage() {
const { t } = useTranslation(["team"]);
return (
<Main className="stack lg">
<InviteCodeSection />
<MemberActions />
<SendouPopover
trigger={
<SendouButton
className="self-start italic"
size="small"
variant="minimal"
>
{t("team:editorsInfo.button")}
</SendouButton>
}
>
{t("team:editorsInfo.popover")}
</SendouPopover>
</Main>
);
}
@ -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<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
@ -258,7 +207,30 @@ function MemberRow({
})}
</select>
</div>
<div className={clsx({ invisible: isSelf })}>
<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,
@ -272,34 +244,14 @@ function MemberRow({
>
<Button
size="tiny"
variant="minimal-destructive"
variant="destructive"
icon={<TrashIcon />}
testId={!isSelf ? "kick-button" : undefined}
>
{t("team:actionButtons.kick")}
</Button>
</FormWithConfirm>
</div>
<div className={clsx({ invisible: isSelf })}>
<FormWithConfirm
dialogHeading={t("team:transferOwnership.header", {
teamName: team.name,
user: member.username,
})}
deleteButtonText={t("team:actionButtons.transferOwnership.confirm")}
fields={[
["_action", "TRANSFER_OWNERSHIP"],
["newOwnerId", member.id],
]}
>
<Button
size="tiny"
variant="minimal-destructive"
testId={!isSelf ? "transfer-ownership-button" : undefined}
>
{t("team:actionButtons.transferOwnership")}
</Button>
</FormWithConfirm>
</div>
<hr className="team__roster__separator" />
</React.Fragment>
);

View File

@ -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<typeof loader> = ({ data }) => {
if (!data) return [];
@ -192,9 +190,12 @@ function ActionButtons() {
{isTeamMember({ user, team }) && !isMainTeam ? (
<ChangeMainTeamButton />
) : null}
{!isTeamOwner({ user, team }) && isTeamMember({ user, team }) ? (
{isTeamMember({ user, team }) ? (
<FormWithConfirm
dialogHeading={t("team:leaveTeam.header", { teamName: team.name })}
dialogHeading={t("team:leaveTeam.header", {
teamName: team.name,
newOwner: resolveNewOwner(team.members)?.username,
})}
deleteButtonText={t("team:actionButtons.leaveTeam.confirm")}
fields={[["_action", "LEAVE_TEAM"]]}
>
@ -207,7 +208,7 @@ function ActionButtons() {
</Button>
</FormWithConfirm>
) : null}
{isTeamOwner({ user, team }) || isAdmin(user) ? (
{isTeamManager({ user, team }) || isAdmin(user) ? (
<LinkButton
size="tiny"
to={manageTeamRosterPage(team.customUrl)}
@ -219,7 +220,7 @@ function ActionButtons() {
{t("team:actionButtons.manageRoster")}
</LinkButton>
) : null}
{isTeamOwner({ user, team }) || isAdmin(user) ? (
{isTeamManager({ user, team }) || isAdmin(user) ? (
<LinkButton
size="tiny"
to={editTeamPage(team.customUrl)}

View File

@ -45,8 +45,12 @@ export const manageRosterSchema = z.union([
userId: id,
}),
z.object({
_action: _action("TRANSFER_OWNERSHIP"),
newOwnerId: id,
_action: _action("ADD_MANAGER"),
userId: id,
}),
z.object({
_action: _action("REMOVE_MANAGER"),
userId: id,
}),
z.object({
_action: _action("UPDATE_MEMBER_ROLE"),

View File

@ -13,6 +13,20 @@ export function isTeamOwner({
return team.members.some((member) => 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;
}

View File

@ -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({
<div>
<Label htmlFor="pick-ban-style">Pick/ban</Label>
<select
className="map-list-dialog__pick-ban-select"
id="pick-ban-style"
value={pickBanStyle ?? "NONE"}
onChange={(e) =>
@ -711,7 +712,7 @@ function SZFirstToggle({
return (
<div className="stack items-center">
<Label htmlFor="sz-first">SZ first</Label>
<Toggle id="sz-first" checked={szFirst} setChecked={setSzFirst} />
<SendouSwitch id="sz-first" isSelected={szFirst} onChange={setSzFirst} />
</div>
);
}
@ -772,10 +773,10 @@ function RoundMapList({
{onPickBanChange ? (
<div>
<Label htmlFor={`pick-ban-${id}`}>Pick/ban</Label>
<Toggle
tiny
checked={Boolean(maps.pickBan)}
setChecked={onPickBanChange}
<SendouSwitch
size="small"
isSelected={Boolean(maps.pickBan)}
onChange={onPickBanChange}
id={`pick-ban-${id}`}
/>
</div>

View File

@ -502,7 +502,7 @@ function ModeProgressIndicator({
trigger={
<SendouButton
variant="minimal"
size="tiny"
size="small"
className="tournament-bracket__mode-progress__image__banned__popover-trigger"
>
<Image

View File

@ -63,7 +63,7 @@ export function TournamentTeamActions() {
) : (
<SendouPopover
trigger={
<SendouButton variant="minimal" size="tiny">
<SendouButton variant="minimal" size="small">
Check-in now
</SendouButton>
}

View File

@ -737,7 +737,7 @@ function SubsPopover({ children }: { children: React.ReactNode }) {
<SendouButton
className="ml-auto"
variant="outlined"
size="tiny"
size="small"
data-testid="add-sub-button"
>
{t("tournament:actions.addSub")}

View File

@ -540,6 +540,10 @@
gap: var(--s-5);
}
.map-list-dialog__pick-ban-select {
text-align: start;
}
.map-list-dialog__map-list-row {
display: flex;
gap: var(--s-2);
@ -547,4 +551,5 @@
margin-block: var(--s-2);
list-style: none;
min-width: 275px;
text-align: start;
}

View File

@ -135,7 +135,7 @@ function AddOrEditSubButton() {
if (!tournament.canAddNewSubPost) {
return (
<SendouPopover
trigger={<SendouButton size="tiny">{buttonText}</SendouButton>}
trigger={<SendouButton size="small">{buttonText}</SendouButton>}
>
{data.hasOwnSubPost
? "Sub post can't be edited anymore since registration has closed"

View File

@ -582,7 +582,7 @@ function CheckIn({
<div className="stack items-center">
<SendouPopover
trigger={
<SendouButton size="tiny">
<SendouButton size="small">
{t("tournament:pre.checkIn.button")}
</SendouButton>
}
@ -696,7 +696,7 @@ function TeamInfo({
<SendouPopover
trigger={
<SendouButton
size="tiny"
size="small"
variant="minimal-destructive"
className="build__small-text"
>

View File

@ -82,7 +82,7 @@ export default function UserBuildsPage() {
<div className="stack sm horizontal items-center justify-end">
<SendouButton
onPress={() => setChangingSorting(true)}
size="tiny"
size="small"
variant="outlined"
icon={<SortIcon />}
data-testid="change-sorting-button"
@ -173,7 +173,7 @@ function BuildsFilters({
<SendouButton
onPress={() => setWeaponFilter("ALL")}
variant={weaponFilter === "ALL" ? undefined : "outlined"}
size="tiny"
size="small"
className="u__build-filter-button"
>
{t("builds:stats.all")} ({data.builds.length})

View File

@ -18,7 +18,6 @@ import { WeaponImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { Toggle } from "~/components/Toggle";
import { StarIcon } from "~/components/icons/Star";
import { StarFilledIcon } from "~/components/icons/StarFilled";
import { TrashIcon } from "~/components/icons/Trash";
@ -54,8 +53,8 @@ import {
} from "~/utils/zod";
import { userParamsSchema } from "../user-page-schemas.server";
import type { UserPageLoaderData } from "./u.$identifier";
import "~/styles/u-edit.css";
import { SendouSwitch } from "~/components/elements/Switch";
const userEditActionSchema = z
.object({
@ -624,9 +623,9 @@ function ShowUniqueDiscordNameToggle() {
<label htmlFor="showDiscordUniqueName">
{t("user:forms.showDiscordUniqueName")}
</label>
<Toggle
checked={checked}
setChecked={setChecked}
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="showDiscordUniqueName"
/>
<FormMessage type="info">
@ -651,9 +650,9 @@ function CommissionsOpenToggle({
return (
<div>
<label htmlFor="commissionsOpen">{t("user:forms.commissionsOpen")}</label>
<Toggle
checked={checked}
setChecked={setChecked}
<SendouSwitch
isSelected={checked}
onChange={setChecked}
name="commissionsOpen"
/>
</div>

View File

@ -130,7 +130,7 @@ function SecondaryTeamsPopover() {
<SendouButton
className="focus-text-decoration self-start"
variant="minimal"
size="tiny"
size="small"
>
<span
className="text-sm font-bold text-main-forced"

View File

@ -405,55 +405,6 @@ dialog::backdrop {
}
}
.toggle {
all: unset;
position: relative;
display: inline-flex;
width: var(--s-11);
height: var(--s-6);
align-items: center;
border-radius: var(--rounded);
background-color: var(--theme-transparent);
cursor: pointer;
}
.toggle.tiny {
width: var(--s-6);
height: var(--s-3);
}
.toggle.checked {
background-color: var(--theme-vibrant);
}
.toggle:active {
transform: initial;
}
.toggle-dot {
display: inline-block;
width: var(--s-4);
height: var(--s-4);
border-radius: 50%;
background-color: white;
transform: translateX(var(--s-1));
transition: transform 0.2s ease;
}
.toggle-dot.tiny {
width: var(--s-3);
height: var(--s-3);
transform: translateX(-0.2rem);
}
.toggle-dot.checked {
transform: translateX(var(--s-6));
}
.toggle-dot.checked.tiny {
transform: translateX(var(--s-4));
}
.button-text-paragraph {
display: flex;
gap: var(--s-1);

View File

@ -45,7 +45,7 @@
color: var(--theme-success);
}
.react-aria-Button.tiny {
.react-aria-Button.small {
font-size: var(--fonts-xs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
@ -112,7 +112,7 @@
margin-inline-end: 0 !important;
}
.react-aria-Button.tiny > .button-icon {
.react-aria-Button.small > .button-icon {
width: 1rem;
margin-inline-end: var(--s-1);
}
@ -254,3 +254,72 @@
background-color: var(--theme-transparent);
outline: initial;
}
.react-aria-Switch {
display: flex;
align-items: center;
gap: 0.571rem;
color: var(--text-color);
forced-color-adjust: none;
font-size: var(--fonts-xs);
font-weight: var(--bold);
margin-block-end: 0;
}
.react-aria-Switch .indicator {
width: var(--s-11);
height: var(--s-6);
background: var(--theme-transparent);
border-radius: 1.143rem;
}
.react-aria-Switch.small .indicator {
width: 2rem;
height: 1.143rem;
}
.react-aria-Switch .indicator:before {
content: "";
display: block;
margin: 0.26rem;
width: var(--s-4);
height: var(--s-4);
background: white;
border-radius: 16px;
transition: transform 200ms;
}
.react-aria-Switch.small .indicator:before {
width: 0.857rem;
height: 0.857rem;
margin: 0.143rem;
}
.react-aria-Switch[data-pressed] .indicator:before {
opacity: 0.8;
}
.react-aria-Switch[data-selected] .indicator {
background: var(--theme-vibrant);
}
.react-aria-Switch[data-selected] .indicator:before {
transform: translateX(125%);
}
.react-aria-Switch.small[data-selected] .indicator:before {
transform: translateX(100%);
}
.react-aria-Switch[data-focus-visible] .indicator {
outline: 2px solid var(--theme-vibrant);
outline-offset: 2px;
}
.react-aria-Switch[data-disabled] .indicator {
opacity: 0.65;
}
.react-aria-Switch[data-disabled] {
cursor: not-allowed;
}

Binary file not shown.

View File

@ -50,7 +50,7 @@ test.describe("Object Damage Calculator", () => {
const dmg = page.getByTestId(cellId("dmg"));
const dmgBefore = (await dmg.textContent())!;
await page.getByTestId("toggle-multi").click();
await page.getByTestId("multi-switch").click();
// Multiplier is on by default
await expect(dmg).not.toHaveText(dmgBefore);

View File

@ -9,7 +9,12 @@ import {
seed,
submit,
} from "~/utils/playwright";
import { TEAM_SEARCH_PAGE, teamPage, userPage } from "~/utils/urls";
import {
TEAM_SEARCH_PAGE,
editTeamPage,
teamPage,
userPage,
} from "~/utils/urls";
test.describe("Team search page", () => {
test("filters teams", async ({ page }) => {
@ -74,7 +79,7 @@ test.describe("Team page", () => {
);
});
test("manages roster", async ({ page }) => {
test("kicks a member & changes a role", async ({ page }) => {
await seed(page);
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
@ -91,15 +96,9 @@ test.describe("Team page", () => {
await modalClickConfirmButton(page);
await isNotVisible(page.getByTestId("member-row-3"));
await page.getByTestId("transfer-ownership-button").first().click();
await modalClickConfirmButton(page);
await navigate({ page, url: teamPage("alliance-rogue") });
await expect(page.getByTestId("member-row-role-0")).toHaveText("Support");
await expect(page).not.toHaveURL(/roster/);
// Owner is not Sendou
await isNotVisible(page.getByTestId(`member-owner-${ADMIN_ID}`));
});
test("deletes team", async ({ page }) => {
@ -176,4 +175,34 @@ test.describe("Team page", () => {
await isNotVisible(page.getByTestId("secondary-team-trigger"));
await expect(page.getByText("Alliance Rogue")).toBeVisible();
});
test("makes another user editor, who can edit the page & becomes owner after the original leaves", async ({
page,
}) => {
await seed(page, "NZAP_IN_TEAM");
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("manage-roster-button").click();
await page.getByTestId("editor-switch").first().click();
await impersonate(page, NZAP_TEST_ID);
await navigate({ page, url: editTeamPage("alliance-rogue") });
await page.getByTestId("bio-textarea").clear();
await page.getByTestId("bio-textarea").fill("from editor");
await page.getByTestId("edit-team-submit-button").click();
await expect(page).toHaveURL(/alliance-rogue/);
await page.getByText("from editor").isVisible();
await impersonate(page, ADMIN_ID);
await navigate({ page, url: teamPage("alliance-rogue") });
await page.getByTestId("leave-team-button").click();
await page.getByText("New owner will be N-ZAP").isVisible();
await modalClickConfirmButton(page);
await isNotVisible(page.getByTestId("leave-team-button"));
});
});

View File

@ -529,7 +529,7 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("delete-bracket-button").last().click();
await page.getByTestId("delete-bracket-button").last().click();
await page.getByLabel("Is follow-up bracket").click();
await page.getByTestId("follow-up-bracket-switch").click();
await page.getByLabel("Format").first().selectOption("Single-elimination");
await submit(page);
@ -914,8 +914,8 @@ test.describe("Tournament bracket", () => {
await expect(page.getByTestId("prepared-maps-check-icon")).toBeVisible();
});
for (const pickBan of ["COUNTERPICK", "BAN_2"]) {
for (const mapPickingStyle of ["AUTO_SZ", "TO"]) {
for (const pickBan of ["COUNTERPICK"]) {
for (const mapPickingStyle of ["TO"]) {
test(`ban/pick ${pickBan} (${mapPickingStyle})`, async ({ page }) => {
const tournamentId = mapPickingStyle === "AUTO_SZ" ? 2 : 4;
const matchId = 2;
@ -930,9 +930,12 @@ test.describe("Tournament bracket", () => {
await page.getByTestId("finalize-bracket-button").click();
await page.getByLabel("Pick/ban").selectOption(pickBan);
if (tournamentId === 2) {
await page.getByTestId("edit-round-maps-button").first().click();
await page.getByLabel("Pick/ban").last().click();
await page.getByTestId("pick-ban-switch").click();
await page.getByTestId("edit-round-maps-button").first().click();
}
await page.getByTestId("confirm-finalize-bracket-button").click();
const teamOneCaptainId = mapPickingStyle === "TO" ? 33 : 29;

View File

@ -2,7 +2,6 @@
"newTeam.header": "Opret nyt hold",
"teamSearch.placeholder": "Søg efter hold eller spiller...",
"actionButtons.leaveTeam": "Forlad hold",
"leaveTeam.header": "Er du sikker på, at du vil forlade {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Forlad hold",
"actionButtons.editTeam": "Rediger hold",
"actionButtons.manageRoster": "Administrer hold",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Neues Team erstellen",
"teamSearch.placeholder": "Suche nach einem Team oder Spieler...",
"actionButtons.leaveTeam": "Team verlassen",
"leaveTeam.header": "Möchtest du {{teamName}} wirklich verlassen?",
"actionButtons.leaveTeam.confirm": "Verlassen",
"actionButtons.editTeam": "Team bearbeiten",
"actionButtons.manageRoster": "Aufstellung bearbeiten",

View File

@ -3,7 +3,7 @@
"teamSearch.placeholder": "Search for a team or player...",
"actionButtons.leaveTeam": "Leave Team",
"actionButtons.makeMainTeam": "Make main team",
"leaveTeam.header": "Are you sure you want to leave {{teamName}}?",
"leaveTeam.header": "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",
@ -39,5 +39,8 @@
"validation.TEAM_FULL": "Team you are trying to join is full.",
"validation.INVITE_CODE_WRONG": "Invite code is wrong.",
"validation.REACHED_TEAM_COUNT_LIMIT": "You have reached the maximum number of teams you can join. (Max 5 for patrons and 2 for others)",
"validation.VALID": "Join {{teamName}}?"
"validation.VALID": "Join {{teamName}}?",
"editor.label": "Editor",
"editorsInfo.button": "What can editors do?",
"editorsInfo.popover": "Editors can do all the same actions as the owner except for kicking the owner and deleting the team."
}

View File

@ -2,7 +2,6 @@
"newTeam.header": "Creando nuevo equipo",
"teamSearch.placeholder": "Buscar un equipo o jugador...",
"actionButtons.leaveTeam": "Abandonar equipo",
"leaveTeam.header": "¿Estás seguro que quieres abandonar a {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar Equipo",
"actionButtons.manageRoster": "Manejar Miembros",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Creando nuevo equipo",
"teamSearch.placeholder": "Buscar un equipo o jugador...",
"actionButtons.leaveTeam": "Abandonar equipo",
"leaveTeam.header": "¿Estás seguro que quieres abandonar a {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Abandonar",
"actionButtons.editTeam": "Editar Equipo",
"actionButtons.manageRoster": "Manejar Miembros",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Création d'une nouvelle équipe",
"teamSearch.placeholder": "Rechercher une équipe ou un joueur...",
"actionButtons.leaveTeam": "Quitter l'équipe",
"leaveTeam.header": "Êtes-vous sûr de vouloir quitter {{teamName}} ?",
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "Gerer la composition de l'équipe",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Création d'une nouvelle équipe",
"teamSearch.placeholder": "Rechercher une équipe ou un joueur...",
"actionButtons.leaveTeam": "Quitter l'équipe",
"leaveTeam.header": "Êtes-vous sûr de vouloir quitter {{teamName}} ?",
"actionButtons.leaveTeam.confirm": "Quitter",
"actionButtons.editTeam": "Modifier l'équipe",
"actionButtons.manageRoster": "Gerer la composition de l'équipe",

View File

@ -2,7 +2,6 @@
"newTeam.header": "יצירת צוות חדש",
"teamSearch.placeholder": "חיפוש צוות או שחקן...",
"actionButtons.leaveTeam": "לעזוב צוות",
"leaveTeam.header": "הנכם בטוחים שאתם רוצים לעזוב את {{teamName}}?",
"actionButtons.leaveTeam.confirm": "לעזוב",
"actionButtons.editTeam": "עריכת צוות",
"actionButtons.manageRoster": "ניהול חברי צוות",

View File

@ -3,7 +3,6 @@
"teamSearch.placeholder": "チーム・プレイヤーを検索...",
"actionButtons.leaveTeam": "チームを抜ける",
"actionButtons.makeMainTeam": "新しいメインチームを作る",
"leaveTeam.header": "チーム {{teamName}} を抜けますか?",
"actionButtons.leaveTeam.confirm": "チームを抜ける",
"actionButtons.editTeam": "チームを編集",
"actionButtons.manageRoster": "名簿管理",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Uwtorzenie nowej drużyny",
"teamSearch.placeholder": "Wyszukaj drużynę lub gracza...",
"actionButtons.leaveTeam": "Opuść drużynę",
"leaveTeam.header": "Napewno chcesz opuścić {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Opuść",
"actionButtons.editTeam": "Edytuj Drużynę",
"actionButtons.manageRoster": "Zarządzaj Członkami",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Criando um novo time",
"teamSearch.placeholder": "Pesquisar por um time ou jogador...",
"actionButtons.leaveTeam": "Sair do Time",
"leaveTeam.header": "Você tem certeza que quer sair do Time {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Sair",
"actionButtons.editTeam": "Editar Time",
"actionButtons.manageRoster": "Gerenciar a lista",

View File

@ -2,7 +2,6 @@
"newTeam.header": "Создать новую команду",
"teamSearch.placeholder": "Поиск игрока или команды...",
"actionButtons.leaveTeam": "Покинуть команду",
"leaveTeam.header": "Вы уверены, что хотите покинуть {{teamName}}?",
"actionButtons.leaveTeam.confirm": "Покинуть",
"actionButtons.editTeam": "Редактировать команду",
"actionButtons.manageRoster": "Управлять составом",

View File

@ -2,7 +2,6 @@
"newTeam.header": "创建一支新队伍",
"teamSearch.placeholder": "搜索队伍或玩家...",
"actionButtons.leaveTeam": "退出队伍",
"leaveTeam.header": "您确定您要退出 {{teamName}} 吗?",
"actionButtons.leaveTeam.confirm": "退出",
"actionButtons.editTeam": "编辑队伍",
"actionButtons.manageRoster": "管理阵容",

View File

@ -0,0 +1,7 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `alter table "AllTeamMember" add "isManager" integer default 0`,
).run();
})();
}