mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
29e0200b6d
commit
8f156fb917
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
22
app/components/elements/Switch.tsx
Normal file
22
app/components/elements/Switch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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'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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -817,7 +817,7 @@ function VoiceChatInfo({
|
|||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
size="small"
|
||||
icon={<Icon className={clsx("q__group-member-vc-icon", color())} />}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
14
app/features/team/TeamMemberRepository.server.ts
Normal file
14
app/features/team/TeamMemberRepository.server.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
68
app/features/team/actions/t.$customUrl.edit.server.ts
Normal file
68
app/features/team/actions/t.$customUrl.edit.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
44
app/features/team/actions/t.$customUrl.join.server.ts
Normal file
44
app/features/team/actions/t.$customUrl.join.server.ts
Normal 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));
|
||||
};
|
||||
89
app/features/team/actions/t.$customUrl.roster.server.ts
Normal file
89
app/features/team/actions/t.$customUrl.roster.server.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { TEAM_MEMBER_ROLES } from "./team-constants";
|
||||
|
||||
export { isTeamOwner } from "./team-utils";
|
||||
22
app/features/team/loaders/t.$customUrl.edit.server.ts
Normal file
22
app/features/team/loaders/t.$customUrl.edit.server.ts
Normal 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 };
|
||||
};
|
||||
78
app/features/team/loaders/t.$customUrl.join.server.ts
Normal file
78
app/features/team/loaders/t.$customUrl.join.server.ts
Normal 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";
|
||||
}
|
||||
29
app/features/team/loaders/t.$customUrl.roster.server.ts
Normal file
29
app/features/team/loaders/t.$customUrl.roster.server.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
},
|
||||
);
|
||||
|
|
@ -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) ? (
|
||||
|
|
|
|||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -502,7 +502,7 @@ function ModeProgressIndicator({
|
|||
trigger={
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
size="small"
|
||||
className="tournament-bracket__mode-progress__image__banned__popover-trigger"
|
||||
>
|
||||
<Image
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function TournamentTeamActions() {
|
|||
) : (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" size="tiny">
|
||||
<SendouButton variant="minimal" size="small">
|
||||
Check-in now
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"newTeam.header": "יצירת צוות חדש",
|
||||
"teamSearch.placeholder": "חיפוש צוות או שחקן...",
|
||||
"actionButtons.leaveTeam": "לעזוב צוות",
|
||||
"leaveTeam.header": "הנכם בטוחים שאתם רוצים לעזוב את {{teamName}}?",
|
||||
"actionButtons.leaveTeam.confirm": "לעזוב",
|
||||
"actionButtons.editTeam": "עריכת צוות",
|
||||
"actionButtons.manageRoster": "ניהול חברי צוות",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
"teamSearch.placeholder": "チーム・プレイヤーを検索...",
|
||||
"actionButtons.leaveTeam": "チームを抜ける",
|
||||
"actionButtons.makeMainTeam": "新しいメインチームを作る",
|
||||
"leaveTeam.header": "チーム {{teamName}} を抜けますか?",
|
||||
"actionButtons.leaveTeam.confirm": "チームを抜ける",
|
||||
"actionButtons.editTeam": "チームを編集",
|
||||
"actionButtons.manageRoster": "名簿管理",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"newTeam.header": "Создать новую команду",
|
||||
"teamSearch.placeholder": "Поиск игрока или команды...",
|
||||
"actionButtons.leaveTeam": "Покинуть команду",
|
||||
"leaveTeam.header": "Вы уверены, что хотите покинуть {{teamName}}?",
|
||||
"actionButtons.leaveTeam.confirm": "Покинуть",
|
||||
"actionButtons.editTeam": "Редактировать команду",
|
||||
"actionButtons.manageRoster": "Управлять составом",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"newTeam.header": "创建一支新队伍",
|
||||
"teamSearch.placeholder": "搜索队伍或玩家...",
|
||||
"actionButtons.leaveTeam": "退出队伍",
|
||||
"leaveTeam.header": "您确定您要退出 {{teamName}} 吗?",
|
||||
"actionButtons.leaveTeam.confirm": "退出",
|
||||
"actionButtons.editTeam": "编辑队伍",
|
||||
"actionButtons.manageRoster": "管理阵容",
|
||||
|
|
|
|||
7
migrations/080-team-manager.js
Normal file
7
migrations/080-team-manager.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "AllTeamMember" add "isManager" integer default 0`,
|
||||
).run();
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user