Cards to match pages

This commit is contained in:
Kalle
2026-07-01 21:09:41 +03:00
parent 36ebdff05d
commit 506792f166
23 changed files with 96 additions and 175 deletions

View File

@@ -42,6 +42,17 @@
}
}
.badgeXs {
width: 0.65rem;
height: 0.65rem;
border-width: 1.5px;
& > svg {
width: 0.45rem;
height: 0.45rem;
}
}
.positive {
background-color: var(--color-success);
}

View File

@@ -19,6 +19,7 @@ const BADGE_ICON: Record<Sentiment, React.ReactNode> = {
};
const SIZE_CLASS = {
xs: styles.badgeXs,
sm: styles.badgeSm,
md: styles.badgeMd,
} as const;
@@ -27,7 +28,7 @@ const SIZE_CLASS = {
* Wraps an avatar (or any node) and overlays a sentiment badge on the bottom-left corner when
* `sentiment` is set: POSITIVE → green check, NEGATIVE → red cross, NEUTRAL → grey dash. Renders the
* children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match
* the wrapped avatar (`sm` for small avatars, `md` for large ones).
* the wrapped avatar (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones).
*/
export function NoteAvatar({
sentiment,

View File

@@ -134,37 +134,6 @@
font-weight: var(--weight-semi);
}
.memberMenuTrigger {
background: none;
border: 0;
padding: 0;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
}
.friendCodeHeader {
text-align: center;
}
.memberMenuHeader {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.memberMenuIgn {
font-size: var(--font-2xs);
color: var(--color-text-high);
}
.memberMenuIgnLabel {
font-weight: var(--weight-bold);
text-transform: uppercase;
font-size: var(--font-3xs);
}
.memberTier {
display: flex;
justify-content: center;
@@ -179,18 +148,6 @@
font-size: var(--font-2xs);
}
.plusTier {
display: flex;
align-items: center;
gap: var(--s-0-5);
background-color: var(--color-bg-higher);
border-radius: var(--radius-full);
padding: var(--s-0-5) var(--s-1-5);
padding-inline-start: var(--s-1);
font-weight: var(--weight-semi);
color: var(--color-text);
}
.subbedOutTrigger {
display: flex;
align-items: center;

View File

@@ -1,23 +1,22 @@
import clsx from "clsx";
import { Armchair, Edit, User } from "lucide-react";
import { Armchair, Edit } 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 { NoteAvatar } from "~/components/NoteAvatar";
import type { TierName } from "~/features/mmr/mmr-constants";
import {
UserCard,
useUserCardData,
} from "~/features/user-card/components/UserCard";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import type { CommonUser } from "~/utils/kysely.server";
import { navIconUrl, tierImageUrl, userPage } from "~/utils/urls";
import { tierImageUrl } from "~/utils/urls";
import { SendouTabPanel } from "../elements/Tabs";
import styles from "./MatchRosterTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
@@ -25,9 +24,7 @@ import { WeaponPool } from "./WeaponPool";
type RosterTabMember = CommonUser & {
tier?: { name: TierName; isPlus: boolean } | "CALCULATING";
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
friendCode?: string | null;
inGameName?: string | null;
};
@@ -160,10 +157,7 @@ function TeamRoster({
<div className={styles.memberTier}>
<MemberTierPopover tier={member.tier} />
</div>
<MemberMeta
plusTier={member.plusTier}
weaponPool={member.weaponPool}
/>
<MemberMeta weaponPool={member.weaponPool} />
</div>
</li>
))}
@@ -397,27 +391,14 @@ function MemberTierPopoverContent({
);
}
function MemberMeta({
plusTier,
weaponPool,
}: {
plusTier?: number | null;
weaponPool?: Array<MainWeaponId>;
}) {
const hasPlusTier = typeof plusTier === "number";
function MemberMeta({ weaponPool }: { weaponPool?: Array<MainWeaponId> }) {
const hasWeapons = weaponPool && weaponPool.length > 0;
if (!hasPlusTier && !hasWeapons) return null;
if (!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}
<WeaponPool weapons={weaponPool} size={18} />
</div>
);
}
@@ -459,68 +440,21 @@ function RosterMemberLink({
member: RosterTabMember;
className?: string;
}) {
const { t } = useTranslation(["friends", "q", "user"]);
const cardData = useUserCardData(member.id);
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 || 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;
// xxx: after usercard everywhere, menu should no longer be necessary
return (
<SendouMenu
trigger={
<ReactAriaButton className={clsx(className, styles.memberMenuTrigger)}>
<UserCard userId={member.id}>
<span className={className}>
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="xs">
<Avatar user={member} size="xxs" />
{nameContent}
</ReactAriaButton>
}
>
<SendouMenuSection
headerText={headerContent}
headerClassName={styles.friendCodeHeader}
>
<SendouMenuItem href={userPage(member)} icon={<User />}>
{t("friends:friendsList.viewUserPage")}
</SendouMenuItem>
</SendouMenuSection>
</SendouMenu>
</NoteAvatar>
<div className={styles.memberNameStack}>
<span>{member.username}</span>
{member.inGameName ? (
<span className={styles.memberInGameName}>{member.inGameName}</span>
) : null}
</div>
</span>
</UserCard>
);
}

View File

@@ -288,7 +288,6 @@ export type ParsedMemento = {
users: Record<
number,
{
plusTier?: PlusTier["tier"];
skill?: TieredSkill | "CALCULATING";
skillDifference?: UserSkillDifference;
}

View File

@@ -196,7 +196,6 @@ export default function MatchPageTestRoute() {
discordAvatar: null,
customUrl: "sendou",
tier: { name: "LEVIATHAN", isPlus: true },
plusTier: 1,
weaponPool: [0, 2000, 4000],
customAvatarUrl: null,
},
@@ -207,7 +206,6 @@ export default function MatchPageTestRoute() {
discordAvatar: null,
customUrl: null,
tier: { name: "DIAMOND", isPlus: false },
plusTier: 2,
weaponPool: [20, 1100],
customAvatarUrl: null,
},
@@ -250,7 +248,6 @@ export default function MatchPageTestRoute() {
discordAvatar: null,
customUrl: null,
tier: { name: "PLATINUM", isPlus: false },
plusTier: 3,
weaponPool: [40, 3000],
customAvatarUrl: null,
},

View File

@@ -1,5 +1,6 @@
import type { LoaderFunctionArgs } from "react-router";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy } from "../../../utils/remix.server";
@@ -36,6 +37,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const mapByMap = await resolveMapByMap({ post, user });
return {
...(await UserCardRepository.userCards({
userIds: participantIds,
viewerId: user.id,
include: { friendCode: true },
})),
post,
chatCode:
(user.roles.includes("STAFF") || participantIds.includes(user.id)) &&

View File

@@ -11,7 +11,7 @@ import { loader } from "../loaders/scrims.$id.server";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["scrims", "q"],
i18n: ["scrims", "q", "user"],
breadcrumb: () => ({
imgPath: navIconUrl("scrims"),
href: scrimsPage(),

View File

@@ -123,7 +123,6 @@ function groupWithTeamAndMembers(
eb
.selectFrom("GroupMember")
.innerJoin("User", "User.id", "GroupMember.userId")
.leftJoin("PlusTier", "User.id", "PlusTier.userId")
.leftJoin("GroupMatchContinueVote", (join) =>
join
.onRef(
@@ -148,7 +147,6 @@ function groupWithTeamAndMembers(
"User.noScreen",
matchProfileWeapons(arrayEb).as("weapons"),
"User.mapModePreferences",
"PlusTier.tier as plusTier",
"GroupMatchContinueVote.isContinuing",
arrayEb
.selectFrom("UserFriendCode")

View File

@@ -164,9 +164,7 @@ function mapRosterMembers(members: MatchData["groupAlpha"]["members"]) {
member.skill === "CALCULATING"
? ("CALCULATING" as const)
: member.skill?.tier,
plusTier: member.plusTier ?? undefined,
weaponPool: member.weapons?.map((w) => w.weaponSplId),
friendCode: member.friendCode,
}));
}

View File

@@ -383,7 +383,6 @@ export function createMatchMemento(
return [
member.id,
{
plusTier: member.plusTier ?? undefined,
skill:
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
},

View File

@@ -5,6 +5,7 @@ import * as Seasons from "~/features/mmr/core/Seasons";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
@@ -34,6 +35,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const match = SendouQ.mapMatch(matchUnmapped, user);
return {
...(await UserCardRepository.userCards({
userIds: matchUsers,
viewerId: user?.id ?? null,
include: { friendCode: true },
})),
match,
reportedWeapons,
isOffSeason: Seasons.current() === null,

View File

@@ -30,7 +30,7 @@ export const meta: MetaFunction = (args) => {
};
export const handle: SendouRouteHandle = {
i18n: ["q"],
i18n: ["q", "user"],
breadcrumb: () => ({
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PAGE,

View File

@@ -72,14 +72,12 @@ export async function findCurrentGroups() {
isTenStar: number;
})[]
| null;
plusTier: Tables["PlusTier"]["tier"] | null;
};
return db
.selectFrom("Group")
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
.innerJoin("User", "User.id", "GroupMember.userId")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("GroupMatch", (join) =>
join.on((eb) =>
eb.or([
@@ -112,7 +110,6 @@ export async function findCurrentGroups() {
note: eb.ref("GroupMember.note"),
weapons: matchProfileWeapons(eb),
languages: eb.ref("User.languages"),
plusTier: eb.ref("PlusTier.tier"),
vc: eb.ref("User.vc"),
}),
])

View File

@@ -29,7 +29,6 @@ function createMember(overrides: Partial<SQGroupMember> = {}): SQGroupMember {
languages: [],
skill: "CALCULATING",
weapons: [],
plusTier: null,
friendCode: null,
inGameName: null,
note: null,
@@ -82,7 +81,6 @@ function createOwnGroupMember(
languages: [],
skill: "CALCULATING",
weapons: [],
plusTier: null,
friendCode: null,
inGameName: null,
note: null,

View File

@@ -24,7 +24,6 @@ import { languagesUnified } from "~/modules/i18n/config";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import {
navIconUrl,
SENDOUQ_LOOKING_PAGE,
specialWeaponImageUrl,
TIERS_PAGE,
@@ -269,7 +268,7 @@ function GroupMember({
<div className="stack xxs" data-testid="sendouq-group-card-member">
<div className={styles.member}>
<div className="text-main-forced stack xs horizontal items-center">
<UserCard userId={member.id}>
<UserCard userId={member.id} withMutualFriends>
<span className="stack xs horizontal items-center">
<NoteAvatar
sentiment={cardData?.privateNote?.sentiment}
@@ -315,12 +314,6 @@ function GroupMember({
<VoiceChatInfo member={member} />
</div>
) : null}
{member.plusTier ? (
<div className={styles.extraInfo}>
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />
{member.plusTier}
</div>
) : null}
{member.friendCode ? (
<SendouPopover
trigger={

View File

@@ -11,6 +11,7 @@ import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
import { matchPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { Status } from "~/modules/brackets-model";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
@@ -218,6 +219,11 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
!isLeagueRoundLocked(tournament, match.roundId);
return {
...(await UserCardRepository.userCards({
userIds: match.players.map((p) => p.id),
viewerId: user?.id ?? null,
include: { friendCode: true },
})),
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
reportedWeapons,

View File

@@ -15,7 +15,7 @@ import { tournamentMatchWebsocketRoom } from "../tournament-match-utils";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["q"],
i18n: ["q", "user"],
};
export default function TournamentMatchPage() {

View File

@@ -47,7 +47,7 @@ export async function userCards({
includeHiddenStats = false,
}: {
userIds: Array<number>;
viewerId: number | null;
viewerId: number | null; // xxx: use actorId
/** Opt-in fields skipped from the query by default; defaults to `false` each. */
include?: { friendCode?: boolean };
/**

View File

@@ -24,7 +24,7 @@
position: relative;
display: flex;
flex-direction: column;
gap: var(--s-3);
gap: var(--s-5);
width: 18rem;
max-width: calc(100vw - var(--s-4));
padding: 0 var(--s-4) var(--s-4);

View File

@@ -46,8 +46,6 @@ import type {
import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog";
import styles from "./UserCard.module.css";
// xxx: also secondary action? e.g. "View tournament" from sidebar
const TENTATEK_BRAND_ID: BrandId = "B10";
const STAT_ORDER: Record<UserCardStat["type"], number> = {
@@ -63,16 +61,21 @@ const STAT_ORDER: Record<UserCardStat["type"], number> = {
* pass `data` directly to bypass the lookup (e.g. the components showcase). When no card data exists
* for the user, the `children` are rendered plain without a trigger.
*
* Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the
* `/user-card/:id/friendship` route the first time the card opens.
* Viewer-relative friendship data (`isFriend`) is lazy-loaded from the `/user-card/:id/friendship`
* route the first time the card opens. Mutual friends are only fetched and shown when
* `withMutualFriends` is set (e.g. the SendouQ looking page); other views (e.g. match pages) skip
* both the extra query and the row.
*/
export function UserCard({
userId,
data: dataProp,
withMutualFriends = false,
children,
}: {
userId?: number;
data?: UserCardData;
/** Fetch and show the mutual friends row. Off by default. */
withMutualFriends?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation(["common", "q"]);
@@ -98,8 +101,8 @@ export function UserCard({
if (typeof data?.id !== "number") return;
friendshipLoadedRef.current = true;
fetcher.load(userCardFriendshipPage(data.id));
}, [isOpen, isOwnCard, data?.id, fetcher.load]);
fetcher.load(userCardFriendshipPage(data.id, { withMutualFriends }));
}, [isOpen, isOwnCard, data?.id, withMutualFriends, fetcher.load]);
const friendship = fetcher.data;
@@ -126,6 +129,7 @@ export function UserCard({
data={data}
friendship={friendship}
isOwnCard={isOwnCard}
withMutualFriends={withMutualFriends}
onEditNote={openNoteDialog}
onDeleteNote={openDeleteConfirm}
/>
@@ -181,6 +185,7 @@ function CardContent({
data,
friendship,
isOwnCard,
withMutualFriends,
onEditNote,
onDeleteNote,
}: {
@@ -188,6 +193,7 @@ function CardContent({
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
friendship: UserCardFriendship | undefined;
isOwnCard: boolean;
withMutualFriends: boolean;
onEditNote: () => void;
onDeleteNote: () => void;
}) {
@@ -287,7 +293,9 @@ function CardContent({
))}
</div>
) : null}
{isOwnCard ? null : <CardMutualFriends friendship={friendship} />}
{isOwnCard || !withMutualFriends ? null : (
<CardMutualFriends friendship={friendship} />
)}
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
<LinkButton
to={userPage(data)}

View File

@@ -9,10 +9,13 @@ export type UserCardFriendshipLoaderData = SerializeFrom<typeof loader>;
/**
* Viewer-relative friendship data for a single user, lazy-loaded by the `UserCard`
* popover when it opens (keeps `isFriend` + `mutualFriends` out of the batched card
* query). Resolves to empty values when there is no logged-in viewer.
* query). Resolves to empty values when there is no logged-in viewer. Mutual friends
* are only resolved when the card opts in via the `mutuals=true` query param (some
* views, e.g. match pages, don't surface them), so the extra query is otherwise skipped.
*/
export const loader = async ({
params,
request,
}: LoaderFunctionArgs): Promise<UserCardFriendship> => {
const viewer = getUser();
const targetUserId = Number(params.id);
@@ -25,6 +28,9 @@ export const loader = async ({
};
}
const withMutualFriends =
new URL(request.url).searchParams.get("mutuals") === "true";
const [friendship, pendingRequest, mutualFriends] = await Promise.all([
FriendRepository.findFriendship({
userOneId: viewer.id,
@@ -34,10 +40,12 @@ export const loader = async ({
senderId: viewer.id,
receiverId: targetUserId,
}),
FriendRepository.findMutualFriends({
loggedInUserId: viewer.id,
targetUserId,
}),
withMutualFriends
? FriendRepository.findMutualFriends({
loggedInUserId: viewer.id,
targetUserId,
})
: [],
]);
return {

View File

@@ -151,8 +151,13 @@ export const PATRONS_LIST_ROUTE = "/patrons-list";
export const NOTIFICATIONS_URL = "/notifications";
export const NOTIFICATIONS_MARK_AS_SEEN_ROUTE = "/notifications/seen";
export const userCardFriendshipPage = (userId: number) =>
`/user-card/${userId}/friendship`;
export const userCardFriendshipPage = (
userId: number,
args?: { withMutualFriends?: boolean },
) =>
`/user-card/${userId}/friendship${
args?.withMutualFriends ? "?mutuals=true" : ""
}`;
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;