mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-06-02 22:26:57 -05:00
531 lines
13 KiB
TypeScript
531 lines
13 KiB
TypeScript
import clsx from "clsx";
|
|
import { Armchair, Edit, User } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Button as ReactAriaButton } from "react-aria-components";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import {
|
|
SendouMenu,
|
|
SendouMenuItem,
|
|
SendouMenuSection,
|
|
} from "~/components/elements/Menu";
|
|
import { SendouPopover } from "~/components/elements/Popover";
|
|
import { Image, TierImage } from "~/components/Image";
|
|
import type { TierName } from "~/features/mmr/mmr-constants";
|
|
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
|
import invariant from "~/utils/invariant";
|
|
import type { CommonUser } from "~/utils/kysely.server";
|
|
import {
|
|
navIconUrl,
|
|
preferenceEmojiUrl,
|
|
tierImageUrl,
|
|
userPage,
|
|
} from "~/utils/urls";
|
|
import { SendouTabPanel } from "../elements/Tabs";
|
|
import styles from "./MatchRosterTab.module.css";
|
|
import { TAB_KEYS } from "./MatchTabs";
|
|
import { WeaponPool } from "./WeaponPool";
|
|
|
|
type RosterTabMember = CommonUser & {
|
|
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
|
|
plusTier?: number | null;
|
|
weaponPool?: Array<MainWeaponId>;
|
|
friendCode?: string | null;
|
|
privateNote?: { sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE" } | null;
|
|
inGameName?: string | null;
|
|
};
|
|
|
|
interface RosterTabTeam {
|
|
team?: {
|
|
id: number;
|
|
name: string;
|
|
url: string;
|
|
avatar?: string;
|
|
};
|
|
defaultName?: string;
|
|
members: Array<RosterTabMember>;
|
|
/** Sub user ids i.e. those who are not the current active roster */
|
|
subbedOut?: Array<number>;
|
|
tier?: { name: TierName; isPlus: boolean };
|
|
}
|
|
|
|
interface MatchRosterTabProps {
|
|
teams: [RosterTabTeam, RosterTabTeam];
|
|
minMembersPerTeam: number;
|
|
canEditSubbedOut?: [boolean, boolean];
|
|
defaultIsEditing?: [boolean, boolean];
|
|
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
|
|
isSubmitting?: boolean;
|
|
}
|
|
|
|
export function MatchRosterTab({
|
|
teams,
|
|
minMembersPerTeam,
|
|
canEditSubbedOut,
|
|
defaultIsEditing,
|
|
onSubbedOutChange,
|
|
isSubmitting,
|
|
}: MatchRosterTabProps) {
|
|
return (
|
|
<SendouTabPanel id={TAB_KEYS.ROSTERS}>
|
|
<div className={styles.rosters}>
|
|
<TeamRoster
|
|
team={teams[0]}
|
|
side="alpha"
|
|
canEditSubbedOut={canEditSubbedOut?.[0] ?? false}
|
|
defaultIsEditing={defaultIsEditing?.[0] ?? false}
|
|
minMembersPerTeam={minMembersPerTeam}
|
|
onSubbedOutChange={onSubbedOutChange}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
<div className={styles.rostersDivider} />
|
|
<TeamRoster
|
|
team={teams[1]}
|
|
side="bravo"
|
|
canEditSubbedOut={canEditSubbedOut?.[1] ?? false}
|
|
defaultIsEditing={defaultIsEditing?.[1] ?? false}
|
|
minMembersPerTeam={minMembersPerTeam}
|
|
onSubbedOutChange={onSubbedOutChange}
|
|
isSubmitting={isSubmitting}
|
|
/>
|
|
</div>
|
|
</SendouTabPanel>
|
|
);
|
|
}
|
|
|
|
function TeamRoster({
|
|
team,
|
|
side,
|
|
canEditSubbedOut,
|
|
defaultIsEditing,
|
|
minMembersPerTeam,
|
|
onSubbedOutChange,
|
|
isSubmitting,
|
|
}: {
|
|
team: RosterTabTeam;
|
|
side: "alpha" | "bravo";
|
|
canEditSubbedOut: boolean;
|
|
defaultIsEditing: boolean;
|
|
minMembersPerTeam: number;
|
|
onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void;
|
|
isSubmitting?: boolean;
|
|
}) {
|
|
const { t } = useTranslation(["common", "q"]);
|
|
const [isEditing, setIsEditing] = useState(defaultIsEditing);
|
|
const [selectedMemberIds, setSelectedMemberIds] = useState<number[]>([]);
|
|
|
|
const dotClassName = side === "alpha" ? styles.teamOneDot : styles.teamTwoDot;
|
|
const label =
|
|
side === "alpha" ? t("q:match.sides.alpha") : t("q:match.sides.bravo");
|
|
|
|
const subbedOutSet = new Set(team.subbedOut);
|
|
const activeMembers = team.members.filter(
|
|
(member) => !subbedOutSet.has(member.id),
|
|
);
|
|
const subbedOutMembers = team.members.filter((member) =>
|
|
subbedOutSet.has(member.id),
|
|
);
|
|
|
|
const showEditButton = canEditSubbedOut && team.team && !isEditing;
|
|
|
|
return (
|
|
<div className={clsx("stack xxs", styles.rosterColumn)}>
|
|
<TeamHeader
|
|
team={team}
|
|
side={side}
|
|
label={label}
|
|
dotClassName={dotClassName}
|
|
/>
|
|
{team.members.length > 0 ? (
|
|
<ul className={styles.rosterMembers}>
|
|
{isEditing
|
|
? team.members.map((member, index) => (
|
|
<li key={member.id}>
|
|
<label className="stack horizontal sm items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedMemberIds.includes(member.id)}
|
|
onChange={() => handleToggleMember(member.id)}
|
|
data-testid={`player-checkbox-${side}-${index}`}
|
|
/>
|
|
<Avatar user={member} size="xxs" />
|
|
<span>{member.username}</span>
|
|
</label>
|
|
</li>
|
|
))
|
|
: activeMembers.map((member) => (
|
|
<li key={member.id} className={styles.memberGrid}>
|
|
<RosterMemberLink
|
|
member={member}
|
|
className={styles.memberLink}
|
|
/>
|
|
<div className={styles.memberTier}>
|
|
<MemberTierPopover tier={member.tier} />
|
|
</div>
|
|
<div className={styles.memberMetaArea}>
|
|
<MemberMeta
|
|
plusTier={member.plusTier}
|
|
weaponPool={member.weaponPool}
|
|
/>
|
|
</div>
|
|
</li>
|
|
))}
|
|
{!isEditing && subbedOutMembers.length > 0 ? (
|
|
<li>
|
|
<SubbedOutPopover members={subbedOutMembers} />
|
|
</li>
|
|
) : null}
|
|
</ul>
|
|
) : null}
|
|
{isEditing ? (
|
|
<div>
|
|
<div className={styles.rosterEditCount}>
|
|
{selectedMemberIds.length}/{minMembersPerTeam}
|
|
</div>
|
|
<div className={styles.rosterEditButtons}>
|
|
<SendouButton
|
|
variant="primary"
|
|
size="small"
|
|
isDisabled={
|
|
isSubmitting || selectedMemberIds.length !== minMembersPerTeam
|
|
}
|
|
onPress={handleSubmit}
|
|
testId={`save-active-roster-button-${side}`}
|
|
>
|
|
{t("common:actions.submit")}
|
|
</SendouButton>
|
|
{defaultIsEditing ? null : (
|
|
<SendouButton
|
|
variant="outlined"
|
|
size="small"
|
|
onPress={handleCancel}
|
|
>
|
|
{t("common:actions.cancel")}
|
|
</SendouButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{showEditButton ? (
|
|
<SendouButton
|
|
icon={<Edit />}
|
|
className="mt-4 mx-auto"
|
|
size="small"
|
|
onPress={() => {
|
|
setSelectedMemberIds(activeMembers.map((m) => m.id));
|
|
setIsEditing(true);
|
|
}}
|
|
testId={`edit-active-roster-button-${side}`}
|
|
>
|
|
{t("common:actions.edit")}
|
|
</SendouButton>
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
function handleToggleMember(memberId: number) {
|
|
setSelectedMemberIds((prev) =>
|
|
prev.includes(memberId)
|
|
? prev.filter((id) => id !== memberId)
|
|
: [...prev, memberId],
|
|
);
|
|
}
|
|
|
|
function handleSubmit() {
|
|
if (!team.team || !onSubbedOutChange) return;
|
|
|
|
const subbedOutIds = team.members
|
|
.filter((m) => !selectedMemberIds.includes(m.id))
|
|
.map((m) => m.id);
|
|
onSubbedOutChange(team.team.id, subbedOutIds);
|
|
setIsEditing(false);
|
|
}
|
|
|
|
function handleCancel() {
|
|
setSelectedMemberIds(activeMembers.map((m) => m.id));
|
|
setIsEditing(false);
|
|
}
|
|
}
|
|
|
|
function TeamHeader({
|
|
team,
|
|
side,
|
|
label,
|
|
dotClassName,
|
|
}: {
|
|
team: RosterTabTeam;
|
|
side: "alpha" | "bravo";
|
|
label: string;
|
|
dotClassName: string;
|
|
}) {
|
|
const tierText = team.tier
|
|
? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}`
|
|
: undefined;
|
|
|
|
if (team.team) {
|
|
return (
|
|
<Link to={team.team.url} className="stack horizontal sm">
|
|
<Avatar
|
|
url={team.team.avatar}
|
|
identiconInput={team.team.name}
|
|
size="sm"
|
|
/>
|
|
<div className="stack justify-center line-height-tight">
|
|
<h2 className="text-main-forced font-bold">{team.team.name}</h2>
|
|
<div className="stack xs horizontal items-center text-lighter">
|
|
<div className={dotClassName} />
|
|
{label}
|
|
{tierText ? (
|
|
<>
|
|
<span>•</span>
|
|
<span className="text-capitalize">{tierText}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
invariant(team.defaultName, "team or defaultName must be provided");
|
|
|
|
return (
|
|
<div className="stack horizontal sm">
|
|
<div className={styles.teamAvatar} data-side={side} />
|
|
<div className="stack justify-center line-height-tight">
|
|
<h2 className="text-main-forced font-bold">{team.defaultName}</h2>
|
|
<div className="stack xs horizontal items-center text-lighter">
|
|
<div className={dotClassName} />
|
|
{label}
|
|
{tierText ? (
|
|
<>
|
|
<span>•</span>
|
|
<span className="text-capitalize">{tierText}</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberTierPopover({
|
|
tier,
|
|
}: {
|
|
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
|
|
}) {
|
|
if (!tier) return null;
|
|
|
|
return (
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton variant="minimal" className={styles.tierBadge}>
|
|
{tier === "CALCULATING" ? (
|
|
<Image
|
|
path={tierImageUrl("CALCULATING")}
|
|
alt=""
|
|
width={22}
|
|
height={22 * 0.8675}
|
|
/>
|
|
) : (
|
|
<TierImage tier={tier} width={22} />
|
|
)}
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<MemberTierPopoverContent tier={tier} />
|
|
</SendouPopover>
|
|
);
|
|
}
|
|
|
|
function MemberTierPopoverContent({
|
|
tier,
|
|
}: {
|
|
tier: { name: TierName; isPlus: boolean } | "CALCULATING";
|
|
}) {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
if (tier === "CALCULATING") {
|
|
return (
|
|
<div className={styles.tierPopover}>
|
|
<Image
|
|
path={tierImageUrl("CALCULATING")}
|
|
alt=""
|
|
width={80}
|
|
height={80 * 0.8675}
|
|
/>
|
|
<span className={styles.tierPopoverName}>
|
|
{t("q:looking.sp.calculating")}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.tierPopover}>
|
|
<TierImage tier={tier} width={80} />
|
|
<span className={styles.tierPopoverName}>
|
|
{tier.name.toLowerCase()}
|
|
{tier.isPlus ? "+" : ""}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemberMeta({
|
|
plusTier,
|
|
weaponPool,
|
|
}: {
|
|
plusTier?: number | null;
|
|
weaponPool?: Array<MainWeaponId>;
|
|
}) {
|
|
const hasPlusTier = typeof plusTier === "number";
|
|
const hasWeapons = weaponPool && weaponPool.length > 0;
|
|
|
|
if (!hasPlusTier && !hasWeapons) return null;
|
|
|
|
return (
|
|
<div className={styles.memberMeta}>
|
|
{hasPlusTier ? (
|
|
<div className={styles.plusTier}>
|
|
<Image path={navIconUrl("plus")} width={16} height={16} alt="" />
|
|
<span>{plusTier}</span>
|
|
</div>
|
|
) : null}
|
|
{hasWeapons ? <WeaponPool weapons={weaponPool} size={18} /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubbedOutPopover({ members }: { members: Array<RosterTabMember> }) {
|
|
const { t } = useTranslation(["q"]);
|
|
|
|
return (
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton variant="minimal" size="small" className="h-max">
|
|
<div className={styles.subbedOutTrigger}>
|
|
<div className={styles.subbedOutIcon}>
|
|
<Armchair size={16} />
|
|
</div>
|
|
+{members.length}
|
|
</div>
|
|
</SendouButton>
|
|
}
|
|
>
|
|
<div className={styles.subbedOutPopover}>
|
|
<div className={styles.subbedOutHeader}>{t("q:match.subbedOut")}</div>
|
|
{members.map((member) => (
|
|
<RosterMemberLink
|
|
key={member.id}
|
|
member={member}
|
|
className="stack horizontal sm items-center"
|
|
/>
|
|
))}
|
|
</div>
|
|
</SendouPopover>
|
|
);
|
|
}
|
|
|
|
function RosterMemberLink({
|
|
member,
|
|
className,
|
|
}: {
|
|
member: RosterTabMember;
|
|
className?: string;
|
|
}) {
|
|
const { t } = useTranslation(["friends", "q", "user"]);
|
|
|
|
const showNoteItem = member.privateNote !== undefined;
|
|
const hasContentBelowName = !!(
|
|
member.tier ||
|
|
typeof member.plusTier === "number" ||
|
|
(member.weaponPool && member.weaponPool.length > 0)
|
|
);
|
|
const showIgnInMenu = hasContentBelowName && !!member.inGameName;
|
|
const showIgnUnderName = !hasContentBelowName && !!member.inGameName;
|
|
const useMenu = !!member.friendCode || showNoteItem || showIgnInMenu;
|
|
|
|
const nameContent = (
|
|
<div className={styles.memberNameStack}>
|
|
<span>{member.username}</span>
|
|
{showIgnUnderName ? (
|
|
<span className={styles.memberInGameName}>{member.inGameName}</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
|
|
if (!useMenu) {
|
|
return (
|
|
<Link to={userPage(member)} className={className}>
|
|
<Avatar user={member} size="xxs" />
|
|
{nameContent}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
const headerContent =
|
|
member.friendCode || showIgnInMenu ? (
|
|
<div className={styles.memberMenuHeader}>
|
|
{member.friendCode ? <span>{`SW-${member.friendCode}`}</span> : null}
|
|
{showIgnInMenu ? (
|
|
<span className={styles.memberMenuIgn}>
|
|
<span className={styles.memberMenuIgnLabel}>
|
|
{t("user:ign.short")}:
|
|
</span>{" "}
|
|
{member.inGameName}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
) : undefined;
|
|
|
|
return (
|
|
<SendouMenu
|
|
trigger={
|
|
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
|
|
<Avatar user={member} size="xxs" />
|
|
{nameContent}
|
|
</ReactAriaButton>
|
|
}
|
|
>
|
|
<SendouMenuSection
|
|
headerText={headerContent}
|
|
headerClassName={styles.friendCodeHeader}
|
|
>
|
|
<SendouMenuItem href={userPage(member)} icon={<User />}>
|
|
{t("friends:friendsList.viewUserPage")}
|
|
</SendouMenuItem>
|
|
{showNoteItem ? (
|
|
<SendouMenuItem
|
|
href={`?note=${member.id}`}
|
|
icon={
|
|
member.privateNote ? (
|
|
<img
|
|
src={preferenceEmojiUrl(
|
|
member.privateNote.sentiment === "POSITIVE"
|
|
? "PREFER"
|
|
: member.privateNote.sentiment === "NEGATIVE"
|
|
? "AVOID"
|
|
: undefined,
|
|
)}
|
|
alt=""
|
|
width={18}
|
|
height={18}
|
|
/>
|
|
) : (
|
|
<Edit />
|
|
)
|
|
}
|
|
>
|
|
{member.privateNote
|
|
? t("q:looking.groups.editNote")
|
|
: t("q:looking.groups.addNote")}
|
|
</SendouMenuItem>
|
|
) : null}
|
|
</SendouMenuSection>
|
|
</SendouMenu>
|
|
);
|
|
}
|