mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-06 20:26:17 -05:00
User card (#3213)
This commit is contained in:
parent
f25a2e410c
commit
a4dfd82141
|
|
@ -122,6 +122,12 @@ export function Avatar({
|
|||
const [isErrored, setIsErrored] = React.useState(false);
|
||||
const isClient = useHydrated();
|
||||
|
||||
// an <img> can finish loading (and fail) before React hydrates and attaches onError, so that
|
||||
// error is missed — re-check on mount and fall back manually so SSR'd avatars still heal
|
||||
const checkAlreadyErrored = (img: HTMLImageElement | null) => {
|
||||
if (img?.complete && img.naturalWidth === 0) setIsErrored(true);
|
||||
};
|
||||
|
||||
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
|
||||
|
||||
const src = url
|
||||
|
|
@ -141,6 +147,7 @@ export function Avatar({
|
|||
return (
|
||||
<div className={clsx(styles.avatarWrapper, className)}>
|
||||
<img
|
||||
ref={checkAlreadyErrored}
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
|
|
|
|||
66
app/components/NoteAvatar.module.css
Normal file
66
app/components/NoteAvatar.module.css
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
bottom: 15%;
|
||||
inset-inline-start: 15%;
|
||||
transform: translate(-50%, 50%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
border: 2px solid var(--color-bg);
|
||||
color: var(--color-bg);
|
||||
|
||||
& > svg {
|
||||
stroke-width: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.badgeMd {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
||||
& > svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badgeSm {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-width: 1.5px;
|
||||
|
||||
& > svg {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badgeXs {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-width: 1.5px;
|
||||
|
||||
& > svg {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
.positive {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
.negative {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background-color: var(--color-text-high);
|
||||
}
|
||||
61
app/components/NoteAvatar.tsx
Normal file
61
app/components/NoteAvatar.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import clsx from "clsx";
|
||||
import { Check, Minus, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import styles from "./NoteAvatar.module.css";
|
||||
|
||||
type Sentiment = Tables["PrivateUserNote"]["sentiment"];
|
||||
|
||||
const BADGE_CLASS: Record<Sentiment, string> = {
|
||||
POSITIVE: styles.positive,
|
||||
NEUTRAL: styles.neutral,
|
||||
NEGATIVE: styles.negative,
|
||||
};
|
||||
|
||||
const BADGE_ICON: Record<Sentiment, React.ReactNode> = {
|
||||
POSITIVE: <Check />,
|
||||
NEUTRAL: <Minus />,
|
||||
NEGATIVE: <X />,
|
||||
};
|
||||
|
||||
const SIZE_CLASS = {
|
||||
xs: styles.badgeXs,
|
||||
sm: styles.badgeSm,
|
||||
md: styles.badgeMd,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 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 (`xs` for tiny avatars, `sm` for small avatars, `md` for large ones).
|
||||
*/
|
||||
export function NoteAvatar({
|
||||
sentiment,
|
||||
size = "md",
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
sentiment?: Sentiment | null;
|
||||
size?: keyof typeof SIZE_CLASS;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={clsx(styles.wrapper, className)}>
|
||||
{children}
|
||||
{sentiment ? (
|
||||
<span
|
||||
className={clsx(
|
||||
styles.badge,
|
||||
SIZE_CLASS[size],
|
||||
BADGE_CLASS[sentiment],
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{BADGE_ICON[sentiment]}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -122,24 +122,16 @@
|
|||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.memberMenuTrigger {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.friendCodeHeader {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memberMenuHeader {
|
||||
.memberNameStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.memberInGameName {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
font-weight: var(--weight-semi);
|
||||
}
|
||||
|
||||
.memberTier {
|
||||
|
|
@ -156,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;
|
||||
|
|
|
|||
|
|
@ -1,28 +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,
|
||||
preferenceEmojiUrl,
|
||||
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";
|
||||
|
|
@ -30,10 +24,7 @@ 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;
|
||||
};
|
||||
|
||||
|
|
@ -166,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>
|
||||
))}
|
||||
|
|
@ -403,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -465,72 +440,21 @@ function RosterMemberLink({
|
|||
member: RosterTabMember;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["friends", "q"]);
|
||||
|
||||
const showNoteItem = member.privateNote !== undefined;
|
||||
const useMenu = !!member.friendCode || showNoteItem;
|
||||
|
||||
const nameContent = <span>{member.inGameName ?? member.username}</span>;
|
||||
|
||||
if (!useMenu) {
|
||||
return (
|
||||
<Link to={userPage(member)} className={className}>
|
||||
<Avatar user={member} size="xxs" />
|
||||
{nameContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const headerContent = member.friendCode ? (
|
||||
<div className={styles.memberMenuHeader}>
|
||||
<span>{`SW-${member.friendCode}`}</span>
|
||||
</div>
|
||||
) : undefined;
|
||||
const cardData = useUserCardData(member.id);
|
||||
|
||||
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>
|
||||
{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>
|
||||
</NoteAvatar>
|
||||
<div className={styles.memberNameStack}>
|
||||
<span>{member.username}</span>
|
||||
{member.inGameName ? (
|
||||
<span className={styles.memberInGameName}>{member.inGameName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</span>
|
||||
</UserCard>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,9 +27,11 @@ import {
|
|||
} from "~/features/plus-voting/core";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.server";
|
||||
import { LUTI_DIVS } from "~/features/scrims/scrims-constants";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { PRESET_COLORS } from "~/features/tier-list-maker/tier-list-maker-constants";
|
||||
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
|
||||
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
|
||||
|
|
@ -179,6 +181,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
adminUserWeaponPool,
|
||||
adminUserWidgets,
|
||||
userProfiles,
|
||||
userCardData,
|
||||
variation === "TEAM_MAP_PREFS" ? undefined : userMapModePreferences,
|
||||
userMatchProfileWeaponPool,
|
||||
seedingSkills,
|
||||
|
|
@ -900,6 +903,66 @@ async function userProfiles() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SendouQ groups draw their members from the lowest user ids, so users at or below this id are
|
||||
* guaranteed full user card data (the rest get a realistic mix of set/unset fields).
|
||||
*/
|
||||
const USER_CARD_SEEDED_USER_ID_CEILING = 100;
|
||||
|
||||
async function userCardData() {
|
||||
for (let id = 2; id < 500; id++) {
|
||||
if (id === ADMIN_ID || id === NZAP_TEST_ID) continue;
|
||||
|
||||
const guaranteed = id <= USER_CARD_SEEDED_USER_ID_CEILING;
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
update "User"
|
||||
set
|
||||
"shortBio" = @shortBio,
|
||||
"div" = @div,
|
||||
"bannerPresetImg" = @bannerPresetImg,
|
||||
"unverifiedPeakXP" = @unverifiedPeakXP
|
||||
where "id" = @id`,
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
shortBio:
|
||||
guaranteed || faker.number.float(1) > 0.4
|
||||
? faker.lorem.sentence()
|
||||
: null,
|
||||
div:
|
||||
guaranteed || faker.number.float(1) > 0.5
|
||||
? faker.helpers.arrayElement(LUTI_DIVS)
|
||||
: null,
|
||||
bannerPresetImg: randomBannerPresetImg(),
|
||||
unverifiedPeakXP:
|
||||
guaranteed || faker.number.float(1) > 0.6 ? randomPeakXp() : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Mix of the three banner sources: null (color derived from user id), a stage banner, an explicit color. */
|
||||
function randomBannerPresetImg() {
|
||||
const roll = faker.number.float(1);
|
||||
if (roll < 0.34) return null;
|
||||
if (roll < 0.67) return String(faker.helpers.arrayElement(stageIds));
|
||||
return faker.helpers.arrayElement(PRESET_COLORS);
|
||||
}
|
||||
|
||||
/** Self-reported peak XP with exactly one division defined (the other null), as the column expects. */
|
||||
function randomPeakXp() {
|
||||
const points = faker.number.int({ min: 2000, max: 3500 });
|
||||
const isTentatek = faker.datatype.boolean();
|
||||
|
||||
return JSON.stringify({
|
||||
overall: points,
|
||||
tentatek: isTentatek ? points : null,
|
||||
takoroka: isTentatek ? null : points,
|
||||
});
|
||||
}
|
||||
|
||||
const randomPreferences = (): UserMapModePreferences => {
|
||||
const modes: UserMapModePreferences["modes"] = modesShort.flatMap((mode) => {
|
||||
if (faker.number.float(1) > 0.5 && mode !== "SZ") return [];
|
||||
|
|
@ -3497,16 +3560,7 @@ const SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG = [100, 101];
|
|||
const SENDOU_FRIEND_IDS_OTHER = [102, 103];
|
||||
|
||||
async function friendships(variation?: SeedVariation | null) {
|
||||
const allFriendIds = [
|
||||
...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
|
||||
...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG,
|
||||
...SENDOU_FRIEND_IDS_OTHER,
|
||||
];
|
||||
|
||||
for (const friendId of allFriendIds) {
|
||||
const userOneId = Math.min(ADMIN_ID, friendId);
|
||||
const userTwoId = Math.max(ADMIN_ID, friendId);
|
||||
|
||||
const insertFriendship = (idA: number, idB: number) =>
|
||||
sql
|
||||
.prepare(
|
||||
/* sql */ `
|
||||
|
|
@ -3514,9 +3568,24 @@ async function friendships(variation?: SeedVariation | null) {
|
|||
values (@userOneId, @userTwoId)
|
||||
`,
|
||||
)
|
||||
.run({ userOneId, userTwoId });
|
||||
.run({ userOneId: Math.min(idA, idB), userTwoId: Math.max(idA, idB) });
|
||||
|
||||
const allFriendIds = [
|
||||
...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
|
||||
...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG,
|
||||
...SENDOU_FRIEND_IDS_OTHER,
|
||||
];
|
||||
|
||||
for (const friendId of allFriendIds) {
|
||||
insertFriendship(ADMIN_ID, friendId);
|
||||
}
|
||||
|
||||
// friendships between some looking-group owners so their user cards show mutual friends with the
|
||||
// admin, while others (e.g. 153 and the additional members) intentionally have none
|
||||
insertFriendship(150, 151);
|
||||
insertFriendship(150, 152);
|
||||
insertFriendship(151, 152);
|
||||
|
||||
if (variation === "NO_SQ_GROUPS" || variation === "TEAM_MAP_PREFS") return;
|
||||
|
||||
for (const friendId of SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS) {
|
||||
|
|
|
|||
|
|
@ -288,7 +288,6 @@ export type ParsedMemento = {
|
|||
users: Record<
|
||||
number,
|
||||
{
|
||||
plusTier?: PlusTier["tier"];
|
||||
skill?: TieredSkill | "CALCULATING";
|
||||
skillDifference?: UserSkillDifference;
|
||||
}
|
||||
|
|
@ -500,12 +499,21 @@ export interface SeedingSkill {
|
|||
type: "RANKED" | "UNRANKED";
|
||||
}
|
||||
|
||||
export interface PeakXP {
|
||||
/** Peak XP across all divisions */
|
||||
overall: number;
|
||||
/** Peak XP (Takoroka division) */
|
||||
takoroka: number | null;
|
||||
/** Peak XP (Tentatek division) */
|
||||
tentatek: number | null;
|
||||
}
|
||||
|
||||
export interface SplatoonPlayer {
|
||||
id: GeneratedAlways<number>;
|
||||
splId: string;
|
||||
userId: number | null;
|
||||
/** Players best XP across both divisions. Denormalized for performance. */
|
||||
peakXp: number | null;
|
||||
peakXp: JSONColumnTypeNullable<PeakXP>;
|
||||
}
|
||||
|
||||
export interface TaggedArt {
|
||||
|
|
@ -1087,11 +1095,17 @@ export type Pronouns = {
|
|||
object: (typeof OBJECT_PRONOUNS)[number];
|
||||
};
|
||||
|
||||
/** Card stat types that can be hidden from a user's card (kept here so `User.hiddenCardStats` does not import a feature module; keep in sync with the feature's `UserCardStat["type"]`). */
|
||||
export type HideableUserCardStat = "XP" | "DIV";
|
||||
|
||||
export interface User {
|
||||
/** 1 = permabanned, timestamp = ban active till then */
|
||||
banned: Generated<number | null>;
|
||||
bannedReason: string | null;
|
||||
/** Shown on old user profile and Plus Voting */
|
||||
bio: string | null;
|
||||
/** Shown on user card */
|
||||
shortBio: string | null;
|
||||
commissionsOpen: Generated<number | null>;
|
||||
commissionsOpenedAt: number | null;
|
||||
commissionText: string | null;
|
||||
|
|
@ -1136,8 +1150,16 @@ export interface User {
|
|||
/** User creation date. Can be null because we did not always save this. */
|
||||
createdAt: number | null;
|
||||
joinOrder: number | null;
|
||||
/** Last message used when creating a tournament sub post */
|
||||
lastSubMessage: string | null;
|
||||
/** User card banner default selection, hex code or stage id. Note: supporters can also upload banner (stored in UserSubmittedImage, referenced by `bannerImgId` which takes precedence) */
|
||||
bannerPresetImg: JSONColumnTypeNullable<string | StageId>;
|
||||
/** Supporter-uploaded user card banner (UserSubmittedImage id). Takes precedence over `bannerPresetImg`. */
|
||||
bannerImgId: number | null;
|
||||
/** Card stat types the user has chosen to hide from their card. */
|
||||
hiddenCardStats: JSONColumnTypeNullable<Array<HideableUserCardStat>>;
|
||||
/** Div in the latest finished LUTI (e.g. "2" or "X"). Must have been in a team that did not drop and the user played at least one match (got result as well) */
|
||||
div: string | null;
|
||||
/** Peak XP as indicated by the user. Should have either `takoroka` or `tentatek` key defined but not both. */
|
||||
unverifiedPeakXP: JSONColumnTypeNullable<PeakXP>;
|
||||
}
|
||||
|
||||
/** Represents User joined with PlusTier table */
|
||||
|
|
@ -1290,6 +1312,9 @@ export interface VideoMatchPlayer {
|
|||
weaponSplId: number;
|
||||
}
|
||||
|
||||
/** `WEST` = Tentatek division, `JPN` = Takoroka division. */
|
||||
export type XRankPlacementRegion = "WEST" | "JPN";
|
||||
|
||||
export interface XRankPlacement {
|
||||
badges: string;
|
||||
bannerSplId: number;
|
||||
|
|
@ -1301,7 +1326,7 @@ export interface XRankPlacement {
|
|||
playerId: number;
|
||||
power: number;
|
||||
rank: number;
|
||||
region: "WEST" | "JPN";
|
||||
region: XRankPlacementRegion;
|
||||
title: string;
|
||||
weaponSplId: MainWeaponId;
|
||||
year: number;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { z } from "zod";
|
|||
import { db } from "~/db/sql";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { i18next } from "~/modules/i18n/i18next.server";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
|
|
@ -44,7 +45,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
.whereRef("UserWeapon.userId", "=", "User.id")
|
||||
.orderBy("UserWeapon.order", "asc"),
|
||||
).as("weapons"),
|
||||
"SplatoonPlayer.peakXp",
|
||||
peakXpOverallSql().as("peakXp"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TeamMemberWithSecondary")
|
||||
|
|
|
|||
|
|
@ -65,7 +65,21 @@ async function insertSplatoonPlayer(args: {
|
|||
userId: number | null;
|
||||
peakXp: number | null;
|
||||
}) {
|
||||
await db.insertInto("SplatoonPlayer").values(args).execute();
|
||||
await db
|
||||
.insertInto("SplatoonPlayer")
|
||||
.values({
|
||||
splId: args.splId,
|
||||
userId: args.userId,
|
||||
peakXp:
|
||||
args.peakXp === null
|
||||
? null
|
||||
: JSON.stringify({
|
||||
overall: args.peakXp,
|
||||
tentatek: args.peakXp,
|
||||
takoroka: null,
|
||||
}),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function findBadgeByCode(code: string) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ExpressionBuilder, NotNull } from "kysely";
|
|||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import { sortBadgesByFavorites } from "~/features/user-page/core/badge-sorting.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
|
|
@ -232,7 +233,7 @@ export async function syncXPBadges() {
|
|||
|
||||
const userTopXPowers = await trx
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select(["userId", "peakXp"])
|
||||
.select(["userId", peakXpOverallSql().as("peakXp")])
|
||||
.where("userId", "is not", null)
|
||||
.where("peakXp", "is not", null)
|
||||
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()
|
||||
|
|
|
|||
|
|
@ -49,12 +49,19 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { Table } from "~/components/Table";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import type { UserCardData } from "~/features/user-card/user-card-types";
|
||||
import type { CustomFieldRenderProps } from "~/form/FormField";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import styles from "../components-showcase.module.css";
|
||||
import { formFieldsShowcaseSchema } from "../form-examples-schema";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user", "q"],
|
||||
};
|
||||
|
||||
export const SECTIONS = [
|
||||
{ title: "Buttons", id: "buttons", component: ButtonsSection },
|
||||
{ title: "Alerts", id: "alerts", component: AlertsSection },
|
||||
|
|
@ -78,6 +85,7 @@ export const SECTIONS = [
|
|||
{ title: "Table", id: "table", component: TableSection },
|
||||
{ title: "Pagination", id: "pagination", component: PaginationSection },
|
||||
{ title: "Avatar", id: "avatar", component: AvatarSection },
|
||||
{ title: "User Card", id: "user-card", component: UserCardSection },
|
||||
{
|
||||
title: "Form Messages",
|
||||
id: "form-messages",
|
||||
|
|
@ -1565,6 +1573,58 @@ function AvatarSection({ id }: { id: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const USER_CARD_DATA = {
|
||||
id: 1,
|
||||
username: "Sendou",
|
||||
discordId: "79237403620945920",
|
||||
discordAvatar: null,
|
||||
customUrl: "sendou",
|
||||
customAvatarUrl: null,
|
||||
banner: { type: "STAGE", stageId: 5 },
|
||||
shortBio: "Very show bio goes here maybe max two lines that gets clamped.",
|
||||
customTheme: null,
|
||||
friendCode: "1234-1234-1234",
|
||||
freeAgentPostId: 1,
|
||||
privateNote: {
|
||||
text: "Played with them, very friendly",
|
||||
sentiment: "POSITIVE",
|
||||
updatedAt: 1704067200,
|
||||
},
|
||||
stats: [
|
||||
{
|
||||
type: "XP",
|
||||
values: [
|
||||
{ isVerified: false, region: "JPN", points: 3123 },
|
||||
{ isVerified: true, region: "JPN", points: 2901 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "SEASON",
|
||||
top: 59,
|
||||
value: {
|
||||
isPlus: true,
|
||||
name: "LEVIATHAN",
|
||||
},
|
||||
},
|
||||
{ type: "DIV", value: "1" },
|
||||
{ type: "PLUS", value: 1 },
|
||||
],
|
||||
} satisfies UserCardData;
|
||||
|
||||
function UserCardSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>User Card</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Click the trigger">
|
||||
<UserCard data={USER_CARD_DATA}>Sendou</UserCard>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessageSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
|
|
|
|||
|
|
@ -117,21 +117,3 @@
|
|||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tier {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
.tierPill {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.tierPillStart {
|
||||
border-radius: 0 var(--radius-box) var(--radius-box) 0;
|
||||
}
|
||||
|
||||
.tierPillEnd {
|
||||
border-radius: var(--radius-box) 0 0 var(--radius-box);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,41 +8,36 @@ import { Divider } from "~/components/Divider";
|
|||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
|
||||
import { lfgNewPostPage } from "~/utils/urls";
|
||||
import { hourDifferenceBetweenTimezones } from "../core/timezone";
|
||||
import type { LFGLoaderData, TiersMap } from "../routes/lfg";
|
||||
import type { LFGLoaderData } from "../routes/lfg";
|
||||
|
||||
import styles from "./LFGPost.module.css";
|
||||
|
||||
type Post = LFGLoaderData["posts"][number];
|
||||
|
||||
export function LFGPost({
|
||||
post,
|
||||
tiersMap,
|
||||
}: {
|
||||
post: Post;
|
||||
tiersMap: TiersMap;
|
||||
}) {
|
||||
export function LFGPost({ post }: { post: Post }) {
|
||||
if (post.team) {
|
||||
return (
|
||||
<TeamLFGPost post={{ ...post, team: post.team }} tiersMap={tiersMap} />
|
||||
);
|
||||
return <TeamLFGPost post={{ ...post, team: post.team }} />;
|
||||
}
|
||||
|
||||
return <UserLFGPost post={post} tiersMap={tiersMap} />;
|
||||
return <UserLFGPost post={post} />;
|
||||
}
|
||||
|
||||
const USER_POST_EXPANDABLE_CRITERIA = 300;
|
||||
function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
|
||||
function UserLFGPost({ post }: { post: Post }) {
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
|
@ -57,13 +52,7 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
|
|||
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
|
||||
<PostPills
|
||||
languages={post.languages}
|
||||
plusTier={post.author.plusTier}
|
||||
timezone={post.timezone}
|
||||
tiers={
|
||||
post.type !== "COACH_FOR_TEAM"
|
||||
? tiersMap.get(post.author.id)
|
||||
: undefined
|
||||
}
|
||||
canEdit={post.author.id === user?.id}
|
||||
postId={post.id}
|
||||
/>
|
||||
|
|
@ -88,10 +77,8 @@ function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
|
|||
|
||||
function TeamLFGPost({
|
||||
post,
|
||||
tiersMap,
|
||||
}: {
|
||||
post: Post & { team: NonNullable<Post["team"]> };
|
||||
tiersMap: TiersMap;
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
const user = useUser();
|
||||
|
|
@ -120,13 +107,9 @@ function TeamLFGPost({
|
|||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<PostTeamMembersFull
|
||||
team={post.team}
|
||||
tiersMap={tiersMap}
|
||||
postId={post.id}
|
||||
/>
|
||||
<PostTeamMembersFull team={post.team} />
|
||||
) : (
|
||||
<PostTeamMembersPeek team={post.team} tiersMap={tiersMap} />
|
||||
<PostTeamMembersPeek team={post.team} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -155,42 +138,21 @@ function PostTeamLogoHeader({ team }: { team: NonNullable<Post["team"]> }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PostTeamMembersPeek({
|
||||
team,
|
||||
tiersMap,
|
||||
}: {
|
||||
team: NonNullable<Post["team"]>;
|
||||
tiersMap: TiersMap;
|
||||
}) {
|
||||
function PostTeamMembersPeek({ team }: { team: NonNullable<Post["team"]> }) {
|
||||
return (
|
||||
<div className="stack sm xs-row horizontal flex-wrap">
|
||||
{team.members.map((member) => (
|
||||
<PostTeamMember key={member.id} member={member} tiersMap={tiersMap} />
|
||||
<PostTeamMember key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTeamMembersFull({
|
||||
team,
|
||||
tiersMap,
|
||||
postId,
|
||||
}: {
|
||||
team: NonNullable<Post["team"]>;
|
||||
tiersMap: TiersMap;
|
||||
postId: number;
|
||||
}) {
|
||||
function PostTeamMembersFull({ team }: { team: NonNullable<Post["team"]> }) {
|
||||
return (
|
||||
<div className="stack lg">
|
||||
{team.members.map((member) => (
|
||||
<div key={member.id} className="stack sm">
|
||||
<PostUserHeader author={member} includeWeapons />
|
||||
<PostPills
|
||||
plusTier={member.plusTier}
|
||||
tiers={tiersMap.get(member.id)}
|
||||
postId={postId}
|
||||
/>
|
||||
</div>
|
||||
<PostUserHeader key={member.id} author={member} includeWeapons />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -198,23 +160,21 @@ function PostTeamMembersFull({
|
|||
|
||||
function PostTeamMember({
|
||||
member,
|
||||
tiersMap,
|
||||
}: {
|
||||
member: NonNullable<Post["team"]>["members"][number];
|
||||
tiersMap: TiersMap;
|
||||
}) {
|
||||
const tiers = tiersMap.get(member.id);
|
||||
const tier = tiers?.latest ?? tiers?.previous;
|
||||
const cardData = useUserCardData(member.id);
|
||||
|
||||
return (
|
||||
<div className="stack sm items-center flex-same-size">
|
||||
<div className="stack sm items-center">
|
||||
<Avatar size="xs" user={member} />
|
||||
<Link to={userPage(member)} className={styles.teamMemberName}>
|
||||
{member.username}
|
||||
</Link>
|
||||
{tier ? <TierImage tier={tier} width={32} /> : null}
|
||||
</div>
|
||||
<UserCard userId={member.id} withMutualFriends>
|
||||
<span className="stack sm items-center">
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" user={member} />
|
||||
</NoteAvatar>
|
||||
<span className={styles.teamMemberName}>{member.username}</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -226,17 +186,24 @@ function PostUserHeader({
|
|||
author: Post["author"];
|
||||
includeWeapons: boolean;
|
||||
}) {
|
||||
const cardData = useUserCardData(author.id);
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<div className="stack sm horizontal items-center">
|
||||
<Avatar size="xsm" user={author} />
|
||||
<div>
|
||||
<div className="stack horizontal sm items-center text-md font-bold">
|
||||
<Link to={userPage(author)} className={styles.userName}>
|
||||
{author.username}
|
||||
</Link>{" "}
|
||||
{author.country ? <Flag countryCode={author.country} tiny /> : null}
|
||||
</div>
|
||||
<div className="stack horizontal sm items-center text-md font-bold">
|
||||
<UserCard userId={author.id} withMutualFriends>
|
||||
<span className="stack sm horizontal items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cardData?.privateNote?.sentiment}
|
||||
size="md"
|
||||
>
|
||||
<Avatar size="xsm" user={author} />
|
||||
</NoteAvatar>
|
||||
<span className={styles.userName}>{author.username}</span>
|
||||
</span>
|
||||
</UserCard>{" "}
|
||||
{author.country ? <Flag countryCode={author.country} tiny /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{includeWeapons ? (
|
||||
|
|
@ -291,16 +258,12 @@ function PostTime({
|
|||
|
||||
function PostPills({
|
||||
timezone,
|
||||
plusTier,
|
||||
languages,
|
||||
tiers,
|
||||
canEdit,
|
||||
postId,
|
||||
}: {
|
||||
timezone?: string | null;
|
||||
plusTier?: number | null;
|
||||
languages?: string | null;
|
||||
tiers?: NonNullable<ReturnType<TiersMap["get"]>>;
|
||||
canEdit?: boolean;
|
||||
postId: number;
|
||||
}) {
|
||||
|
|
@ -316,10 +279,6 @@ function PostPills({
|
|||
<PostTimezonePill timezone={timezone} />
|
||||
)}
|
||||
{!isHydrated && <PostTimezonePillPlaceholder />}
|
||||
{typeof plusTier === "number" && (
|
||||
<PostPlusServerPill plusTier={plusTier} />
|
||||
)}
|
||||
{tiers && <PostSkillPills tiers={tiers} />}
|
||||
{typeof languages === "string" && (
|
||||
<PostLanguagePill languages={languages} />
|
||||
)}
|
||||
|
|
@ -332,66 +291,6 @@ function PostTimezonePillPlaceholder() {
|
|||
return <div className={clsx(styles.pill, styles.pillPlaceholder)} />;
|
||||
}
|
||||
|
||||
const currentSeasonNth = Seasons.currentOrPrevious()!.nth;
|
||||
|
||||
function PostSkillPills({
|
||||
tiers,
|
||||
}: {
|
||||
tiers: NonNullable<ReturnType<TiersMap["get"]>>;
|
||||
}) {
|
||||
const hasBoth = tiers.latest && tiers.previous;
|
||||
|
||||
return (
|
||||
<div className="stack xxxs horizontal">
|
||||
{tiers.latest ? (
|
||||
<PostSkillPill
|
||||
seasonNth={currentSeasonNth}
|
||||
tier={tiers.latest}
|
||||
cut={hasBoth ? "END" : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{tiers.previous ? (
|
||||
<PostSkillPill
|
||||
seasonNth={currentSeasonNth - 1}
|
||||
tier={tiers.previous}
|
||||
cut={hasBoth ? "START" : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSkillPill({
|
||||
seasonNth,
|
||||
tier,
|
||||
cut,
|
||||
}: {
|
||||
seasonNth: number;
|
||||
tier: TieredSkill["tier"];
|
||||
cut?: "START" | "END";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.pill, styles.tierPill, {
|
||||
[styles.tierPillStart]: cut === "START",
|
||||
[styles.tierPillEnd]: cut === "END",
|
||||
})}
|
||||
>
|
||||
S{seasonNth}
|
||||
<TierImage tier={tier} width={32} className={styles.tier} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostPlusServerPill({ plusTier }: { plusTier: number }) {
|
||||
return (
|
||||
<div className={styles.pill}>
|
||||
<Image alt="" path={navIconUrl("plus")} size={18} />
|
||||
{plusTier}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostTimezonePill({ timezone }: { timezone: string }) {
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const diff = hourDifferenceBetweenTimezones(userTimezone, timezone);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import * as R from "remeda";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
|
||||
|
|
@ -9,9 +11,19 @@ export const loader = async () => {
|
|||
const user = getUser();
|
||||
const posts = await LFGRepository.posts(user);
|
||||
|
||||
const cardUserIds = R.unique(
|
||||
posts.flatMap((post) => [
|
||||
post.author.id,
|
||||
...(post.team?.members ?? []).map((member) => member.id),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
posts,
|
||||
tiersMap: postsUsersTiersMap(posts),
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import styles from "./lfg.module.css";
|
|||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["lfg"],
|
||||
i18n: ["lfg", "user", "q"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("lfg"),
|
||||
href: LFG_PAGE,
|
||||
|
|
@ -131,7 +131,7 @@ export default function LFGPage() {
|
|||
className={clsx("stack sm", styles.post)}
|
||||
>
|
||||
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
|
||||
<LFGPost post={post} tiersMap={tiersMap} />
|
||||
<LFGPost post={post} />
|
||||
</div>
|
||||
))}
|
||||
{filteredPosts.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { db } from "~/db/sql";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import { commonUserSelect } from "~/utils/kysely.server";
|
||||
import * as StreamRanking from "../sidebar/core/StreamRanking";
|
||||
|
||||
|
|
@ -34,15 +35,11 @@ export function findXRankStreams() {
|
|||
.selectFrom("LiveStream")
|
||||
.innerJoin("User", "User.twitch", "LiveStream.twitch")
|
||||
.innerJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
|
||||
.where(
|
||||
"SplatoonPlayer.peakXp",
|
||||
">=",
|
||||
StreamRanking.minXpForStreamToBeShown(),
|
||||
)
|
||||
.where(peakXpOverallSql(), ">=", StreamRanking.minXpForStreamToBeShown())
|
||||
.where("LiveStream.twitch", "is not", null)
|
||||
.select((eb) => [
|
||||
...commonUserSelect(eb),
|
||||
"SplatoonPlayer.peakXp",
|
||||
peakXpOverallSql<number>().as("peakXp"),
|
||||
"LiveStream.viewerCount",
|
||||
"LiveStream.thumbnailUrl",
|
||||
"LiveStream.twitch as twitchUsername",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -20,12 +20,17 @@ import { SendouPopover } from "~/components/elements/Popover";
|
|||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { ModeImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import TimePopover from "~/components/TimePopover";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls";
|
||||
import { scrimPage, tournamentRegisterPage } from "~/utils/urls";
|
||||
import type { ScrimPost, ScrimPostRequest } from "../scrims-types";
|
||||
import { formatFlexTimeDisplay } from "../scrims-utils";
|
||||
import styles from "./ScrimCard.module.css";
|
||||
|
|
@ -142,11 +147,19 @@ function ScrimTeamAvatar({
|
|||
teamName: string;
|
||||
owner: ScrimPost["users"][number];
|
||||
}) {
|
||||
const cardData = useUserCardData(owner.id);
|
||||
|
||||
if (teamAvatarUrl) {
|
||||
return <Avatar size="xs" url={teamAvatarUrl} alt={teamName} />;
|
||||
}
|
||||
|
||||
return <Avatar size="xs" user={owner} alt={owner.username} />;
|
||||
return (
|
||||
<UserCard userId={owner.id} withMutualFriends>
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
|
||||
<Avatar size="xs" user={owner} alt={owner.username} />
|
||||
</NoteAvatar>
|
||||
</UserCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrimVisibilityPopover() {
|
||||
|
|
@ -178,20 +191,28 @@ function ScrimTeamMembersPopover({ users }: { users: ScrimPost["users"] }) {
|
|||
>
|
||||
<div className="stack md">
|
||||
{users.map((user) => (
|
||||
<Link
|
||||
to={userPage(user)}
|
||||
key={user.id}
|
||||
className="stack horizontal sm"
|
||||
>
|
||||
<Avatar size="xxs" user={user} />
|
||||
{user.username}
|
||||
</Link>
|
||||
<ScrimTeamMemberRow key={user.id} user={user} />
|
||||
))}
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrimTeamMemberRow({ user }: { user: ScrimPost["users"][number] }) {
|
||||
const cardData = useUserCardData(user.id);
|
||||
|
||||
return (
|
||||
<UserCard userId={user.id} withMutualFriends>
|
||||
<span className="stack horizontal sm items-center">
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="xs">
|
||||
<Avatar size="xxs" user={user} />
|
||||
</NoteAvatar>
|
||||
{user.username}
|
||||
</span>
|
||||
</UserCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrimTournamentPopover({
|
||||
tournament,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -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,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
const mapByMap = await resolveMapByMap({ post, user });
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: participantIds,
|
||||
include: { friendCode: true },
|
||||
})),
|
||||
post,
|
||||
chatCode:
|
||||
(user.roles.includes("STAFF") || participantIds.includes(user.id)) &&
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import * as AssociationsRepository from "~/features/associations/AssociationRepository.server";
|
||||
import * as Association from "~/features/associations/core/Association";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import { parseSearchParams } from "~/utils/remix.server";
|
||||
import * as TeamRepository from "../../team/TeamRepository.server";
|
||||
import * as Scrim from "../core/Scrim";
|
||||
|
|
@ -43,7 +45,19 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
}),
|
||||
}));
|
||||
|
||||
const cardUserIds = R.unique(
|
||||
posts.flatMap((post) => [
|
||||
...post.users.map((user) => user.id),
|
||||
...post.requests.flatMap((request) =>
|
||||
request.users.map((user) => user.id),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
posts: dividePosts(posts, user?.id),
|
||||
teams: user ? await TeamRepository.teamsByMemberUserId(user.id) : [],
|
||||
filters: filters ?? Scrim.defaultFilters(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import styles from "./scrims.module.css";
|
|||
export type NewRequestFormFields = z.infer<typeof newRequestSchema>;
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["calendar", "scrims"],
|
||||
i18n: ["calendar", "scrims", "user", "q"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("scrims"),
|
||||
href: scrimsPage(),
|
||||
|
|
|
|||
|
|
@ -3,9 +3,32 @@ import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
|||
import {
|
||||
formatFlexTimeDisplay,
|
||||
generateTimeOptions,
|
||||
parseLutiDivFromName,
|
||||
parseMapPoolInput,
|
||||
} from "./scrims-utils";
|
||||
|
||||
describe("parseLutiDivFromName", () => {
|
||||
it("parses a numeric division", () => {
|
||||
expect(parseLutiDivFromName("LUTI: Season 15 - Division 2")).toBe("2");
|
||||
});
|
||||
|
||||
it("parses division X", () => {
|
||||
expect(parseLutiDivFromName("LUTI Season 15 Division X")).toBe("X");
|
||||
});
|
||||
|
||||
it("parses a two-digit division without matching a single digit", () => {
|
||||
expect(parseLutiDivFromName("LUTI Season 15 Div 10")).toBe("10");
|
||||
});
|
||||
|
||||
it("returns null when no division token is present", () => {
|
||||
expect(parseLutiDivFromName("Leagues Under The Ink Season 15")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an out-of-range division", () => {
|
||||
expect(parseLutiDivFromName("LUTI Division 12")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateTimeOptions", () => {
|
||||
it("includes both start and end times", () => {
|
||||
const start = new Date("2025-01-15T14:15:00");
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as R from "remeda";
|
|||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import * as Scrim from "./core/Scrim";
|
||||
import { LUTI_DIVS } from "./scrims-constants";
|
||||
import type { LutiDiv, ScrimPost } from "./scrims-types";
|
||||
|
||||
export const getPostRequestCensor =
|
||||
|
|
@ -53,6 +54,20 @@ export const parseLutiDiv = (div: number): LutiDiv => {
|
|||
return String(div) as LutiDiv;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the LUTI division (e.g. `"X"`, `"2"`) from a tournament name such as
|
||||
* "LUTI: Season 15 - Division 2". Returns `null` if no valid division token is found.
|
||||
*/
|
||||
export const parseLutiDivFromName = (name: string): LutiDiv | null => {
|
||||
const match = name.match(/\bdiv(?:ision)?\.?\s*(X|11|10|[1-9])\b/i);
|
||||
if (!match) return null;
|
||||
|
||||
const token = match[1].toUpperCase();
|
||||
return (LUTI_DIVS as readonly string[]).includes(token)
|
||||
? (token as LutiDiv)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const serializeLutiDiv = (div: LutiDiv): number => {
|
||||
if (div === "X") return 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { db } from "~/db/sql";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
|
|
@ -9,7 +8,6 @@ import {
|
|||
refreshSendouQInstance,
|
||||
SendouQ,
|
||||
} from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { SENDOUQ_LOOKING_ROOM } from "~/features/sendouq/q-constants";
|
||||
import { SendouQError } from "~/features/sendouq/q-utils.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
|
|
@ -26,7 +24,6 @@ import {
|
|||
parseRequestPayload,
|
||||
} from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { sendouQMatchPage } from "~/utils/urls";
|
||||
import * as RejoinVote from "../core/RejoinVote";
|
||||
import * as SendouQMatch from "../core/SendouQMatch";
|
||||
import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas";
|
||||
|
|
@ -263,15 +260,6 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case "ADD_PRIVATE_USER_NOTE": {
|
||||
await PrivateUserNoteRepository.upsertOwnNote({
|
||||
sentiment: data.sentiment,
|
||||
targetId: data.targetId,
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(matchId));
|
||||
}
|
||||
case "UNDO_MATCH_REPORT": {
|
||||
const result = await SQMatchRepository.undoMatchReport({
|
||||
matchId,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
|
||||
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
|
||||
import { MatchTabs } from "~/components/match-page/MatchTabs";
|
||||
|
|
@ -7,8 +6,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import { sendouQMatchPage, teamPage } from "~/utils/urls";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import {
|
||||
resolveTimelineMaps,
|
||||
resolveTimelineSpChanges,
|
||||
|
|
@ -16,14 +14,11 @@ import {
|
|||
} from "../core/match-timeline";
|
||||
import * as SendouQMatch from "../core/SendouQMatch";
|
||||
import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server";
|
||||
import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog";
|
||||
import { SendouQMatchActionTab } from "./SendouQMatchActionTab";
|
||||
|
||||
export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
||||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
const currentMap = data.match.currentMap;
|
||||
|
|
@ -91,78 +86,56 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
|
|||
? ["action"]
|
||||
: undefined;
|
||||
|
||||
const ownGroup =
|
||||
userSide === "ALPHA"
|
||||
? data.match.groupAlpha
|
||||
: userSide === "BRAVO"
|
||||
? data.match.groupBravo
|
||||
: null;
|
||||
const addingNoteFor = ownGroup?.members.find(
|
||||
(m) => m.id === safeNumberParse(searchParams.get("note")),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddPrivateNoteDialog
|
||||
aboutUser={addingNoteFor}
|
||||
close={() => navigate(sendouQMatchPage(data.match.id))}
|
||||
<MatchTabs tabs={tabs} alertTabs={alertTabs}>
|
||||
{isLocked || hasReportedMaps ? (
|
||||
<MatchResultTab
|
||||
teams={resolveTimelineTeams(data.match, t)}
|
||||
score={{ alpha: alphaWins, bravo: bravoWins }}
|
||||
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
|
||||
spChanges={resolveTimelineSpChanges(data.match)}
|
||||
isOngoing={!isLocked && hasReportedMaps}
|
||||
>
|
||||
{data.match.cancelRequestedByUserId ? (
|
||||
<p className="text-lighter text-xxs text-center mt-4">
|
||||
{t("q:match.canceled.detail", {
|
||||
requester: resolveCancelRequesterUsername(data.match),
|
||||
accepter: resolveCancelAccepterUsername(data.match),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</MatchResultTab>
|
||||
) : null}
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
canEditSubbedOut={[false, false]}
|
||||
teams={[
|
||||
{
|
||||
team: mapRosterTeam(data.match.groupAlpha.team),
|
||||
defaultName: t("q:match.groupAlpha"),
|
||||
members: mapRosterMembers(data.match.groupAlpha.members),
|
||||
tier: data.match.groupAlpha.tier ?? undefined,
|
||||
},
|
||||
{
|
||||
team: mapRosterTeam(data.match.groupBravo.team),
|
||||
defaultName: t("q:match.groupBravo"),
|
||||
members: mapRosterMembers(data.match.groupBravo.members),
|
||||
tier: data.match.groupBravo.tier ?? undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<MatchTabs tabs={tabs} alertTabs={alertTabs}>
|
||||
{isLocked || hasReportedMaps ? (
|
||||
<MatchResultTab
|
||||
teams={resolveTimelineTeams(data.match, t)}
|
||||
score={{ alpha: alphaWins, bravo: bravoWins }}
|
||||
maps={resolveTimelineMaps(data.match, data.reportedWeapons)}
|
||||
spChanges={resolveTimelineSpChanges(data.match)}
|
||||
isOngoing={!isLocked && hasReportedMaps}
|
||||
>
|
||||
{data.match.cancelRequestedByUserId ? (
|
||||
<p className="text-lighter text-xxs text-center mt-4">
|
||||
{t("q:match.canceled.detail", {
|
||||
requester: resolveCancelRequesterUsername(data.match),
|
||||
accepter: resolveCancelAccepterUsername(data.match),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</MatchResultTab>
|
||||
) : null}
|
||||
<MatchRosterTab
|
||||
minMembersPerTeam={4}
|
||||
canEditSubbedOut={[false, false]}
|
||||
teams={[
|
||||
{
|
||||
team: mapRosterTeam(data.match.groupAlpha.team),
|
||||
defaultName: t("q:match.groupAlpha"),
|
||||
members: mapRosterMembers(data.match.groupAlpha.members, {
|
||||
viewerId: user?.id,
|
||||
isOwnTeam: userSide === "ALPHA",
|
||||
}),
|
||||
tier: data.match.groupAlpha.tier ?? undefined,
|
||||
},
|
||||
{
|
||||
team: mapRosterTeam(data.match.groupBravo.team),
|
||||
defaultName: t("q:match.groupBravo"),
|
||||
members: mapRosterMembers(data.match.groupBravo.members, {
|
||||
viewerId: user?.id,
|
||||
isOwnTeam: userSide === "BRAVO",
|
||||
}),
|
||||
tier: data.match.groupBravo.tier ?? undefined,
|
||||
},
|
||||
]}
|
||||
{showActionTab ? (
|
||||
<SendouQMatchActionTab
|
||||
data={data}
|
||||
currentMap={currentMap ?? undefined}
|
||||
ownTeamId={ownTeamId}
|
||||
reportedCount={
|
||||
data.match.mapList.filter((m) => m.winnerGroupId !== null).length
|
||||
}
|
||||
viewerSide={userSide}
|
||||
/>
|
||||
{showActionTab ? (
|
||||
<SendouQMatchActionTab
|
||||
data={data}
|
||||
currentMap={currentMap ?? undefined}
|
||||
ownTeamId={ownTeamId}
|
||||
reportedCount={
|
||||
data.match.mapList.filter((m) => m.winnerGroupId !== null).length
|
||||
}
|
||||
viewerSide={userSide}
|
||||
/>
|
||||
) : null}
|
||||
</MatchTabs>
|
||||
</>
|
||||
) : null}
|
||||
</MatchTabs>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -184,23 +157,14 @@ function resolveCancelAccepterUsername(match: MatchData) {
|
|||
);
|
||||
}
|
||||
|
||||
function mapRosterMembers(
|
||||
members: MatchData["groupAlpha"]["members"],
|
||||
{ viewerId, isOwnTeam }: { viewerId?: number; isOwnTeam: boolean },
|
||||
) {
|
||||
function mapRosterMembers(members: MatchData["groupAlpha"]["members"]) {
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
tier:
|
||||
member.skill === "CALCULATING"
|
||||
? ("CALCULATING" as const)
|
||||
: member.skill?.tier,
|
||||
plusTier: member.plusTier ?? undefined,
|
||||
weaponPool: member.weapons?.map((w) => w.weaponSplId),
|
||||
friendCode: member.friendCode,
|
||||
privateNote:
|
||||
viewerId !== undefined && isOwnTeam && member.id !== viewerId
|
||||
? (member.privateNote ?? null)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -383,7 +383,6 @@ export function createMatchMemento(
|
|||
return [
|
||||
member.id,
|
||||
{
|
||||
plusTier: member.plusTier ?? undefined,
|
||||
skill:
|
||||
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import { getUser } from "~/features/auth/core/user.server";
|
|||
import { chatAccessible } from "~/features/chat/chat-utils";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.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";
|
||||
|
|
@ -30,14 +30,15 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
const isStaff = user?.roles.includes("STAFF") ?? false;
|
||||
const isParticipant = Boolean(user && matchUsers.includes(user.id));
|
||||
|
||||
const [privateNotes, reportedWeapons] = await Promise.all([
|
||||
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
|
||||
ReportedWeaponRepository.findByMatchId(matchId),
|
||||
]);
|
||||
const reportedWeapons = await ReportedWeaponRepository.findByMatchId(matchId);
|
||||
|
||||
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
|
||||
const match = SendouQ.mapMatch(matchUnmapped, user);
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: matchUsers,
|
||||
include: { friendCode: isStaff || isParticipant },
|
||||
})),
|
||||
match,
|
||||
reportedWeapons,
|
||||
isOffSeason: Seasons.current() === null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { _action, falsyToNull, id, weaponSplId } from "~/utils/zod";
|
||||
import { _action, id, weaponSplId } from "~/utils/zod";
|
||||
|
||||
export const matchSchema = z.union([
|
||||
z.object({
|
||||
|
|
@ -21,15 +20,6 @@ export const matchSchema = z.union([
|
|||
weaponSplId,
|
||||
mapIndex: z.coerce.number().int().nonnegative(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ADD_PRIVATE_USER_NOTE"),
|
||||
comment: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
|
||||
targetId: id,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UNDO_MATCH_REPORT"),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { assertUnreachable } from "~/utils/types";
|
|||
import { navIconUrl, SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls";
|
||||
import { groupAfterMorph } from "../core/groups";
|
||||
import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
|
||||
import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants";
|
||||
import { lookingSchema } from "../q-schemas.server";
|
||||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
|
|
@ -331,11 +330,6 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await PrivateUserNoteRepository.deleteOwnNoteById(data.targetId);
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,9 @@ function createMember(overrides: Partial<SQGroupMember> = {}): SQGroupMember {
|
|||
languages: [],
|
||||
skill: "CALCULATING",
|
||||
weapons: [],
|
||||
plusTier: null,
|
||||
friendCode: null,
|
||||
inGameName: null,
|
||||
note: null,
|
||||
privateNote: null,
|
||||
pronouns: null,
|
||||
skillDifference: undefined,
|
||||
noScreen: undefined,
|
||||
|
|
@ -83,11 +81,9 @@ function createOwnGroupMember(
|
|||
languages: [],
|
||||
skill: "CALCULATING",
|
||||
weapons: [],
|
||||
plusTier: null,
|
||||
friendCode: null,
|
||||
inGameName: null,
|
||||
note: null,
|
||||
privateNote: null,
|
||||
pronouns: null,
|
||||
skillDifference: undefined,
|
||||
noScreen: undefined,
|
||||
|
|
|
|||
|
|
@ -40,22 +40,6 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.avatarPositive {
|
||||
outline: 2px solid var(--color-success-low);
|
||||
}
|
||||
|
||||
.avatarNeutral {
|
||||
outline: 2px solid var(--color-warning-low);
|
||||
}
|
||||
|
||||
.avatarNegative {
|
||||
outline: 2px solid var(--color-error-low);
|
||||
}
|
||||
|
||||
.tier {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
|
@ -97,23 +81,6 @@
|
|||
height: 24px;
|
||||
}
|
||||
|
||||
.addNoteButton {
|
||||
border: none;
|
||||
padding: 0 var(--s-1-5);
|
||||
color: var(--color-text-accent);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
background-color: var(--color-bg);
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
border-radius: var(--radius-selector);
|
||||
|
||||
& > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.noteTextarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,33 @@
|
|||
import clsx from "clsx";
|
||||
import type { SqlBool } from "kysely";
|
||||
import { Mic, PenSquare, Star, Trash, Volume2, VolumeX } from "lucide-react";
|
||||
import { Mic, Star, Volume2, VolumeX } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { ParsedMemento } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
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,
|
||||
tierImageUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import type {
|
||||
SQGroup,
|
||||
|
|
@ -37,12 +38,6 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
|||
import { resolveFutureMatchModes } from "../q-utils";
|
||||
import styles from "./GroupCard.module.css";
|
||||
|
||||
const SENTIMENT_STYLES = {
|
||||
POSITIVE: styles.avatarPositive,
|
||||
NEUTRAL: styles.avatarNeutral,
|
||||
NEGATIVE: styles.avatarNegative,
|
||||
} as const;
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
action,
|
||||
|
|
@ -50,8 +45,6 @@ export function GroupCard({
|
|||
hideVc = false,
|
||||
hideWeapons = false,
|
||||
hideNote: _hidenote = false,
|
||||
showAddNote,
|
||||
showNote = false,
|
||||
ownGroup,
|
||||
layout = "desktop",
|
||||
}: {
|
||||
|
|
@ -61,13 +54,10 @@ export function GroupCard({
|
|||
hideVc?: SqlBool;
|
||||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
ownGroup?: SQOwnGroup;
|
||||
layout?: "mobile" | "desktop";
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const hideNote =
|
||||
|
|
@ -104,8 +94,6 @@ export function GroupCard({
|
|||
hideWeapons={hideWeapons}
|
||||
hideNote={hideNote}
|
||||
enableKicking={enableKicking}
|
||||
showNote={showNote}
|
||||
showAddNote={showAddNote && member.id !== user?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -263,8 +251,6 @@ function GroupMember({
|
|||
hideWeapons,
|
||||
hideNote,
|
||||
enableKicking,
|
||||
showAddNote,
|
||||
showNote,
|
||||
}: {
|
||||
member: SQGroupMember;
|
||||
showActions: boolean;
|
||||
|
|
@ -273,70 +259,37 @@ function GroupMember({
|
|||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
enableKicking?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "user"]);
|
||||
const user = useUser();
|
||||
const cardData = useUserCardData(member.id);
|
||||
|
||||
return (
|
||||
<div className="stack xxs" data-testid="sendouq-group-card-member">
|
||||
<div className={styles.member}>
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
{showNote && member.privateNote ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal">
|
||||
<Avatar
|
||||
user={member}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
styles.avatar,
|
||||
SENTIMENT_STYLES[member.privateNote.sentiment],
|
||||
)}
|
||||
/>
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{member.privateNote.text}
|
||||
<div
|
||||
className={clsx(
|
||||
"stack sm horizontal justify-between items-center",
|
||||
{ "mt-2": member.privateNote.text },
|
||||
)}
|
||||
<UserCard userId={member.id} withMutualFriends>
|
||||
<span className="stack xs horizontal items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cardData?.privateNote?.sentiment}
|
||||
size="sm"
|
||||
>
|
||||
<LocaleTime
|
||||
date={member.privateNote.updatedAt}
|
||||
options={{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
}}
|
||||
className="text-xxs text-lighter"
|
||||
/>
|
||||
<DeletePrivateNoteForm
|
||||
name={member.username}
|
||||
targetId={member.id}
|
||||
/>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
) : (
|
||||
<Avatar user={member} size="xs" />
|
||||
)}
|
||||
<Link to={userPage(member)} className={styles.name}>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.username
|
||||
)}
|
||||
</Link>
|
||||
<Avatar user={member} size="xs" />
|
||||
</NoteAvatar>
|
||||
<span className={styles.name}>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.username
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
{member.pronouns ? (
|
||||
<span className="text-lighter ml-1 text-xxxs">
|
||||
{member.pronouns.subject}/{member.pronouns.object}
|
||||
|
|
@ -361,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={
|
||||
|
|
@ -378,19 +325,6 @@ function GroupMember({
|
|||
SW-{member.friendCode}
|
||||
</SendouPopover>
|
||||
) : null}
|
||||
{showAddNote ? (
|
||||
<LinkButton
|
||||
to={`?note=${member.id}`}
|
||||
icon={<PenSquare />}
|
||||
className={clsx(styles.addNoteButton, {
|
||||
[styles.addNoteButtonEdit]: member.privateNote,
|
||||
})}
|
||||
>
|
||||
{member.privateNote
|
||||
? t("q:looking.groups.editNote")
|
||||
: t("q:looking.groups.addNote")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
|
||||
<div className={styles.extraInfo}>
|
||||
|
|
@ -524,30 +458,6 @@ function AddPrivateNoteForm({
|
|||
);
|
||||
}
|
||||
|
||||
function DeletePrivateNoteForm({
|
||||
targetId,
|
||||
name,
|
||||
}: {
|
||||
targetId: number;
|
||||
name: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("q:privateNote.delete.header", { name })}
|
||||
fields={[
|
||||
["targetId", targetId],
|
||||
["_action", "DELETE_PRIVATE_USER_NOTE"],
|
||||
]}
|
||||
>
|
||||
<SubmitButton variant="minimal-destructive" size="small" type="submit">
|
||||
<Trash className="small-icon" />
|
||||
</SubmitButton>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import { dbInsertUsers, dbReset, withUser } from "~/utils/Test";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as SQGroupRepository from "../SQGroupRepository.server";
|
||||
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
|
||||
|
||||
|
|
@ -69,29 +67,6 @@ const createMatch = async (
|
|||
.execute();
|
||||
};
|
||||
|
||||
const ownNotesOf = async (authorId: number, targetUserIds?: number[]) => {
|
||||
const user = await UserRepository.findLeanById(authorId);
|
||||
return withUser(user!, () =>
|
||||
PrivateUserNoteRepository.ownNotes(targetUserIds),
|
||||
);
|
||||
};
|
||||
|
||||
const createPrivateNote = async (
|
||||
authorId: number,
|
||||
targetId: number,
|
||||
sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE",
|
||||
text = "test note",
|
||||
) => {
|
||||
const user = await UserRepository.findLeanById(authorId);
|
||||
await withUser(user!, () =>
|
||||
PrivateUserNoteRepository.upsertOwnNote({
|
||||
targetId,
|
||||
sentiment,
|
||||
text,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const insertSkill = async (userId: number, ordinal: number, season = 1) => {
|
||||
await db
|
||||
.insertInto("Skill")
|
||||
|
|
@ -273,8 +248,7 @@ describe("SendouQ", () => {
|
|||
test("returns empty array when no groups exist", async () => {
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
|
@ -283,8 +257,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([1, 2, 3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeUndefined();
|
||||
|
|
@ -294,35 +267,19 @@ describe("SendouQ", () => {
|
|||
await createGroup([1, 2]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeDefined();
|
||||
expect(groups[0].members).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("attaches private notes to members", async () => {
|
||||
await createGroup([1, 2]);
|
||||
await createPrivateNote(3, 2, "POSITIVE", "Great player");
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(3);
|
||||
const groups = SendouQ.previewGroups(3, notes);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
const member = groups[0].members?.find((m) => m.id === 2);
|
||||
expect(member?.privateNote).toBeDefined();
|
||||
expect(member?.privateNote?.sentiment).toBe("POSITIVE");
|
||||
});
|
||||
|
||||
test("removes inviteCode and chatCode from all groups", async () => {
|
||||
await createGroup([1, 2], { inviteCode: "CODE1" });
|
||||
await createGroup([3, 4, 5, 6], { inviteCode: "CODE2" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
for (const group of groups) {
|
||||
|
|
@ -337,8 +294,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([7, 8, 9]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
|
||||
|
|
@ -354,8 +310,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([5, 6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
const fullGroup = groups.find((g) => g.members === undefined);
|
||||
const partialGroup = groups.find((g) => g.members !== undefined);
|
||||
|
|
@ -399,8 +354,7 @@ describe("SendouQ", () => {
|
|||
.execute();
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].id).toBe(group1Id);
|
||||
|
|
@ -427,8 +381,7 @@ describe("SendouQ", () => {
|
|||
.execute();
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
|
|
@ -448,36 +401,13 @@ describe("SendouQ", () => {
|
|||
const partialGroupId = await createGroup([6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].id).toBe(partialGroupId);
|
||||
expect(groups[1].id).toBe(fullGroupId);
|
||||
});
|
||||
|
||||
test("sorts by sentiment first, then tier within same sentiment", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 500);
|
||||
await insertSkill(3, 2000);
|
||||
await insertSkill(4, 1050);
|
||||
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createPrivateNote(1, 2, "POSITIVE");
|
||||
await createPrivateNote(1, 3, "NEGATIVE");
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
expect(groups[0].members![0].id).toBe(2);
|
||||
expect(groups[1].members![0].id).toBe(4);
|
||||
expect(groups[2].members![0].id).toBe(3);
|
||||
});
|
||||
|
||||
test("handles viewer without skill gracefully", async () => {
|
||||
await insertSkill(2, 500);
|
||||
await insertSkill(3, 2000);
|
||||
|
|
@ -486,8 +416,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.previewGroups(1, notes);
|
||||
const groups = SendouQ.previewGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
|
@ -508,8 +437,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([1, 2, 3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(5);
|
||||
const groups = SendouQ.lookingGroups(5, notes);
|
||||
const groups = SendouQ.lookingGroups(5);
|
||||
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
|
@ -526,8 +454,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([4], { status: "ACTIVE" });
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
|
|
@ -542,8 +469,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members![0].id).toBe(3);
|
||||
|
|
@ -554,8 +480,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([3, 4]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members?.some((m) => m.id === 1)).toBe(false);
|
||||
|
|
@ -569,8 +494,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([11, 12, 13, 14]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toBeUndefined();
|
||||
|
|
@ -584,8 +508,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([10, 11, 12, 13]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].members).toHaveLength(1);
|
||||
|
|
@ -600,8 +523,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([9, 10, 11, 12]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
const groupSizes = groups.map((g) => g.members!.length);
|
||||
|
|
@ -617,8 +539,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([8, 9, 10, 11]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups).toHaveLength(3);
|
||||
const groupSizes = groups.map((g) => g.members!.length);
|
||||
|
|
@ -655,8 +576,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
const fullGroups = groups.filter((g) => g.members === undefined);
|
||||
expect(fullGroups.some((g) => g.isReplay)).toBe(true);
|
||||
|
|
@ -679,8 +599,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group.isReplay).toBe(false);
|
||||
|
|
@ -693,8 +612,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group.isReplay).toBe(false);
|
||||
|
|
@ -718,8 +636,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
const partialGroup = groups.find((g) =>
|
||||
g.members?.some((m) => m.id === 5),
|
||||
|
|
@ -742,8 +659,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([5, 6, 7, 8]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
const fullGroup = groups.find((g) => g.members === undefined);
|
||||
expect(fullGroup).toBeDefined();
|
||||
|
|
@ -754,8 +670,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([2, 3]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
const partialGroup = groups.find((g) => g.members?.length === 2);
|
||||
expect(partialGroup).toBeDefined();
|
||||
|
|
@ -768,8 +683,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([3, 4, 5, 6]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
for (const group of groups) {
|
||||
expect(group).not.toHaveProperty("inviteCode");
|
||||
|
|
@ -778,76 +692,6 @@ describe("SendouQ", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("private note sorting", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(8);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("users with positive note sorted first", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 5, "POSITIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(5);
|
||||
});
|
||||
|
||||
test("users with negative note sorted last", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 5, "NEGATIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[groups.length - 1].members![0].id).toBe(5);
|
||||
});
|
||||
|
||||
test("group with both negative and positive sentiment sorted last", async () => {
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await createPrivateNote(1, 6, "POSITIVE");
|
||||
await createPrivateNote(1, 7, "NEGATIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[groups.length - 1].members?.some((m) => m.id === 6)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill-based sorting", () => {
|
||||
beforeEach(async () => {
|
||||
refreshUserSkills(1);
|
||||
|
|
@ -858,26 +702,7 @@ describe("SendouQ", () => {
|
|||
dbReset();
|
||||
});
|
||||
|
||||
test("sentiment still takes priority over skill", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 500);
|
||||
await insertSkill(4, 2000);
|
||||
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([4]);
|
||||
|
||||
await createPrivateNote(1, 4, "POSITIVE");
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(4);
|
||||
});
|
||||
|
||||
test("groups with closer skill sorted first within same sentiment", async () => {
|
||||
test("groups with closer skill sorted first", async () => {
|
||||
await insertSkill(1, 1000);
|
||||
await insertSkill(2, 1050);
|
||||
await insertSkill(3, 500);
|
||||
|
|
@ -890,8 +715,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(2);
|
||||
});
|
||||
|
|
@ -914,8 +738,7 @@ describe("SendouQ", () => {
|
|||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups.length).toBeGreaterThan(0);
|
||||
expect(groups[0].id).toBe(closerGroup);
|
||||
|
|
@ -946,8 +769,7 @@ describe("SendouQ", () => {
|
|||
await createGroup([1]);
|
||||
await refreshSendouQInstance();
|
||||
|
||||
const notes = await ownNotesOf(1);
|
||||
const groups = SendouQ.lookingGroups(1, notes);
|
||||
const groups = SendouQ.lookingGroups(1);
|
||||
|
||||
expect(groups[0].members![0].id).toBe(3);
|
||||
expect(groups[1].members![0].id).toBe(2);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { AuthenticatedUser } from "~/features/auth/core/user.server";
|
|||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
|
||||
import { type TieredSkill, userSkills } from "~/features/mmr/tiered.server";
|
||||
import type * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import * as SendouQMatch from "~/features/sendouq-match/core/SendouQMatch";
|
||||
import type * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
|
|
@ -22,9 +21,6 @@ import { tierDifferenceToRangeOrExact } from "./groups.server";
|
|||
type DBGroupRow = Awaited<
|
||||
ReturnType<typeof SQGroupRepository.findCurrentGroups>
|
||||
>[number];
|
||||
type DBPrivateNoteRow = Awaited<
|
||||
ReturnType<typeof PrivateUserNoteRepository.ownNotes>
|
||||
>[number];
|
||||
type DBRecentlyFinishedMatchRow = Awaited<
|
||||
ReturnType<typeof SQGroupRepository.findRecentlyFinishedMatches>
|
||||
>[number];
|
||||
|
|
@ -42,7 +38,6 @@ export type SQOwnGroup = SerializeFrom<
|
|||
NonNullable<ReturnType<SendouQClass["findOwnGroup"]>>
|
||||
>;
|
||||
export type SQMatch = SerializeFrom<ReturnType<SendouQClass["mapMatch"]>>;
|
||||
export type SQMatchGroup = SQMatch["groupAlpha"] | SQMatch["groupBravo"];
|
||||
export type SQGroupMember = NonNullable<SQGroup["members"]>[number];
|
||||
|
||||
const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const;
|
||||
|
|
@ -91,7 +86,6 @@ class SendouQClass {
|
|||
|
||||
return {
|
||||
...member,
|
||||
privateNote: null as DBPrivateNoteRow | null,
|
||||
languages: member.languages?.split(",") || [],
|
||||
skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
|
||||
mapModePreferences: undefined,
|
||||
|
|
@ -165,8 +159,6 @@ class SendouQClass {
|
|||
match: DBMatch,
|
||||
/** The authenticated user viewing the match (if any) */
|
||||
user?: AuthenticatedUser,
|
||||
/** Array of private user notes to include */
|
||||
notes: DBPrivateNoteRow[] = [],
|
||||
) {
|
||||
const viewerSide = SendouQMatch.resolveGroupMemberOf({
|
||||
groupAlpha: match.groupAlpha,
|
||||
|
|
@ -199,7 +191,6 @@ class SendouQClass {
|
|||
return {
|
||||
...member,
|
||||
skill: match.memento?.users[member.id]?.skill,
|
||||
privateNote: null as DBPrivateNoteRow | null,
|
||||
skillDifference: match.memento?.users[member.id]?.skillDifference,
|
||||
noScreen: undefined,
|
||||
isContinuing:
|
||||
|
|
@ -244,8 +235,8 @@ class SendouQClass {
|
|||
...match,
|
||||
chatCode: isMatchInsider ? match.chatCode : undefined,
|
||||
currentMap,
|
||||
groupAlpha: this.#getAddMemberPrivateNoteMapper(notes)(alphaCensored),
|
||||
groupBravo: this.#getAddMemberPrivateNoteMapper(notes)(bravoCensored),
|
||||
groupAlpha: alphaCensored,
|
||||
groupBravo: bravoCensored,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -256,14 +247,11 @@ class SendouQClass {
|
|||
previewGroups(
|
||||
/** The ID of the user viewing the preview */
|
||||
userId: number,
|
||||
/** Array of private user notes to include */
|
||||
notes: DBPrivateNoteRow[],
|
||||
) {
|
||||
const usersTier = this.#getUserTier(userId);
|
||||
return this.groups
|
||||
.filter((group) => this.#isSuitableLookingGroup({ group }))
|
||||
.map(this.#getAddMemberPrivateNoteMapper(notes))
|
||||
.sort(this.#getSkillAndNoteSortComparator(usersTier))
|
||||
.sort(this.#getSkillSortComparator(usersTier))
|
||||
.map((group) => this.#addPreviewTierRange(group))
|
||||
.map((group) => this.#censorGroup(group));
|
||||
}
|
||||
|
|
@ -271,14 +259,12 @@ class SendouQClass {
|
|||
/**
|
||||
* Returns groups that are available for matchmaking for a specific user based on their current group size.
|
||||
* Filters groups based on member count compatibility, activity status, and excludes stale groups.
|
||||
* Results are sorted by sentiment (notes), tier difference, and activity.
|
||||
* Results are sorted by tier difference and activity.
|
||||
* @returns Array of compatible groups sorted by relevance, or empty array if user has no group
|
||||
*/
|
||||
lookingGroups(
|
||||
/** The ID of the user looking for groups */
|
||||
userId: number,
|
||||
/** Array of private user notes to include */
|
||||
notes: DBPrivateNoteRow[] = [],
|
||||
) {
|
||||
const ownGroup = this.findOwnGroup(userId);
|
||||
if (!ownGroup) return [];
|
||||
|
|
@ -302,8 +288,7 @@ class SendouQClass {
|
|||
)
|
||||
.map(this.#getGroupReplayMapper(userId))
|
||||
.map(this.#getAddTierRangeMapper(ownGroup.tier))
|
||||
.map(this.#getAddMemberPrivateNoteMapper(notes))
|
||||
.sort(this.#getSkillAndNoteSortComparator(ownGroup.tier))
|
||||
.sort(this.#getSkillSortComparator(ownGroup.tier))
|
||||
.map((group) => this.#censorGroup(group));
|
||||
}
|
||||
|
||||
|
|
@ -416,27 +401,10 @@ class SendouQClass {
|
|||
return skill.tier;
|
||||
}
|
||||
|
||||
#getAddMemberPrivateNoteMapper(notes: DBPrivateNoteRow[]) {
|
||||
return <T extends { members: { id: number }[] }>(group: T) => {
|
||||
const membersWithNotes = group.members.map((member) => {
|
||||
const note = notes.find((n) => n.targetUserId === member.id);
|
||||
return {
|
||||
...member,
|
||||
privateNote: note ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...group,
|
||||
members: membersWithNotes,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
#getSkillAndNoteSortComparator(ownTier?: TieredSkill["tier"] | null) {
|
||||
#getSkillSortComparator(ownTier?: TieredSkill["tier"] | null) {
|
||||
return <
|
||||
T extends {
|
||||
members: { privateNote: DBPrivateNoteRow | null }[];
|
||||
members: unknown[];
|
||||
tierRange: TierRange | null;
|
||||
tier: TieredSkill["tier"] | null;
|
||||
latestActionAt: number;
|
||||
|
|
@ -452,26 +420,6 @@ class SendouQClass {
|
|||
return aIsFull ? 1 : -1;
|
||||
}
|
||||
|
||||
const getGroupSentimentScore = (group: T) => {
|
||||
const hasNegative = group.members.some(
|
||||
(m) => m.privateNote?.sentiment === "NEGATIVE",
|
||||
);
|
||||
const hasPositive = group.members.some(
|
||||
(m) => m.privateNote?.sentiment === "POSITIVE",
|
||||
);
|
||||
|
||||
if (hasNegative) return -1;
|
||||
if (hasPositive) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const scoreA = getGroupSentimentScore(a);
|
||||
const scoreB = getGroupSentimentScore(b);
|
||||
|
||||
if (scoreA !== scoreB) {
|
||||
return scoreB - scoreA;
|
||||
}
|
||||
|
||||
if (a.tierRange && b.tierRange) {
|
||||
if (a.tierRange.diff[1] !== b.tierRange.diff[1]) {
|
||||
return a.tierRange.diff[1] - b.tierRange.diff[1];
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import { cachedStreams } from "~/features/sendouq-streams/core/streams.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import { groupExpiryStatus } from "../core/groups";
|
||||
import { SendouQ } from "../core/SendouQ.server";
|
||||
import * as PrivateUserNoteRepository from "../PrivateUserNoteRepository.server";
|
||||
import { sqRedirectIfNeeded } from "../q-utils.server";
|
||||
|
||||
export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
|
|
@ -14,15 +15,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
|||
url.searchParams.get("preview") === "true" &&
|
||||
user.roles.includes("SUPPORTER");
|
||||
|
||||
const privateNotes = await PrivateUserNoteRepository.ownNotes(
|
||||
SendouQ.usersInQueue,
|
||||
);
|
||||
|
||||
const ownGroup = SendouQ.findOwnGroup(user.id);
|
||||
const groups =
|
||||
isPreview && !ownGroup
|
||||
? SendouQ.previewGroups(user.id, privateNotes)
|
||||
: SendouQ.lookingGroups(user.id, privateNotes);
|
||||
? SendouQ.previewGroups(user.id)
|
||||
: SendouQ.lookingGroups(user.id);
|
||||
|
||||
if (!isPreview) {
|
||||
sqRedirectIfNeeded({
|
||||
|
|
@ -31,11 +28,23 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
|||
});
|
||||
}
|
||||
|
||||
const groupsToShow =
|
||||
ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
|
||||
? []
|
||||
: groups;
|
||||
|
||||
const cardUserIds = R.unique([
|
||||
...(ownGroup?.members ?? []).map((member) => member.id),
|
||||
...groupsToShow.flatMap((group) =>
|
||||
(group.members ?? []).map((member) => member.id),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
groups:
|
||||
ownGroup && groupExpiryStatus(ownGroup.latestActionAt) === "EXPIRED"
|
||||
? []
|
||||
: groups,
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
groups: groupsToShow,
|
||||
ownGroup,
|
||||
likes: ownGroup
|
||||
? await SQGroupRepository.allLikesByGroupId(ownGroup.id)
|
||||
|
|
|
|||
|
|
@ -80,10 +80,6 @@ export const lookingSchema = z.union([
|
|||
z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE_PRIVATE_USER_NOTE"),
|
||||
targetId: id,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const weaponUsageSearchParamsSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Placeholder } from "~/components/Placeholder";
|
|||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useWebsocketRevalidation } from "~/features/chat/chat-hooks";
|
||||
import type { UserCardData } from "~/features/user-card/user-card-types";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
|
|
@ -239,6 +240,8 @@ function Groups() {
|
|||
const isFullGroup =
|
||||
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
|
||||
|
||||
const groups = sortGroupsByPrivateNoteSentiment(data.groups, data.userCards);
|
||||
|
||||
const invitedGroupsDesktop = (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader isMobile={isMobile}>
|
||||
|
|
@ -248,7 +251,7 @@ function Groups() {
|
|||
: "q:looking.columns.invited",
|
||||
)}
|
||||
</ColumnHeader>
|
||||
{data.groups
|
||||
{groups
|
||||
.filter((group) =>
|
||||
data.likes.given.some((like) => like.groupId === group.id),
|
||||
)
|
||||
|
|
@ -258,7 +261,6 @@ function Groups() {
|
|||
key={group.id}
|
||||
group={group}
|
||||
action="UNLIKE"
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
|
|
@ -272,7 +274,7 @@ function Groups() {
|
|||
<ColumnHeader isMobile={isMobile}>
|
||||
{t("q:looking.columns.myGroup")}
|
||||
</ColumnHeader>
|
||||
<GroupCard group={data.ownGroup} showNote ownGroup={data.ownGroup} />
|
||||
<GroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
|
||||
{data.ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={data.ownGroup.inviteCode}
|
||||
|
|
@ -286,12 +288,12 @@ function Groups() {
|
|||
</div>
|
||||
) : null;
|
||||
|
||||
const neutralGroups = data.groups.filter(
|
||||
const neutralGroups = groups.filter(
|
||||
(group) =>
|
||||
!data.likes.given.some((like) => like.groupId === group.id) &&
|
||||
!data.likes.received.some((like) => like.groupId === group.id),
|
||||
);
|
||||
const groupsReceivedLikesFrom = data.groups.filter((group) =>
|
||||
const groupsReceivedLikesFrom = groups.filter((group) =>
|
||||
data.likes.received.some((like) => like.groupId === group.id),
|
||||
);
|
||||
|
||||
|
|
@ -341,7 +343,7 @@ function Groups() {
|
|||
{t("q:looking.columns.available")}
|
||||
</ColumnHeader>
|
||||
{(isMobile
|
||||
? data.groups.filter(
|
||||
? groups.filter(
|
||||
(group) =>
|
||||
!data.likes.received.some(
|
||||
(like) => like.groupId === group.id,
|
||||
|
|
@ -360,7 +362,6 @@ function Groups() {
|
|||
? "UNLIKE"
|
||||
: "LIKE"
|
||||
}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
|
|
@ -388,7 +389,6 @@ function Groups() {
|
|||
key={group.id}
|
||||
group={group}
|
||||
action={action()}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
|
|
@ -426,7 +426,6 @@ function Groups() {
|
|||
key={group.id}
|
||||
group={group}
|
||||
action={action()}
|
||||
showNote
|
||||
ownGroup={data.ownGroup}
|
||||
layout={layout}
|
||||
/>
|
||||
|
|
@ -439,6 +438,38 @@ function Groups() {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Floats groups the viewer has a positive private note on up and groups with a
|
||||
* negative note down, while preserving the server's tier/activity ordering
|
||||
* within each sentiment bucket and keeping full (censored) groups last. The
|
||||
* note sentiment is read from the already-loaded `userCards` data so the server
|
||||
* does not need to attach notes to group members.
|
||||
*/
|
||||
function sortGroupsByPrivateNoteSentiment<
|
||||
T extends { members?: { id: number }[] },
|
||||
>(groups: T[], userCards: Map<number, UserCardData>): T[] {
|
||||
const sentimentScore = (group: T) => {
|
||||
if (!group.members) return 0;
|
||||
|
||||
let score = 0;
|
||||
for (const member of group.members) {
|
||||
const sentiment = userCards.get(member.id)?.privateNote?.sentiment;
|
||||
if (sentiment === "NEGATIVE") return -1;
|
||||
if (sentiment === "POSITIVE") score = 1;
|
||||
}
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
return groups.toSorted((a, b) => {
|
||||
const aIsFull = !a.members;
|
||||
const bIsFull = !b.members;
|
||||
if (aIsFull !== bIsFull) return aIsFull ? 1 : -1;
|
||||
|
||||
return sentimentScore(b) - sentimentScore(a);
|
||||
});
|
||||
}
|
||||
|
||||
function ColumnHeader({
|
||||
isMobile,
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { Pencil } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useLocation } from "react-router";
|
||||
import { LinkButton } from "~/components/elements/Button";
|
||||
import { ModeImage } from "~/components/Image";
|
||||
import type { Preference, UserMapModePreferences } from "~/db/tables";
|
||||
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
|
||||
|
|
@ -7,52 +10,70 @@ import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-
|
|||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { userCardEditPage } from "~/utils/urls";
|
||||
import type { loader } from "../loaders/settings.server";
|
||||
import { updateMatchProfileSchema } from "../match-profile-schemas";
|
||||
import { ModeMapPoolPicker } from "./ModeMapPoolPicker";
|
||||
import { PreferenceRadioGroup } from "./PreferenceRadioGroup";
|
||||
|
||||
export function MatchProfileTab() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const location = useLocation();
|
||||
const matchProfile = data.matchProfile;
|
||||
|
||||
if (!matchProfile) return null;
|
||||
|
||||
return (
|
||||
<SendouForm
|
||||
schema={updateMatchProfileSchema}
|
||||
defaultValues={{
|
||||
mapModePreferences: preferencesFromRaw(matchProfile.mapModePreferences),
|
||||
weaponPool: (matchProfile.weaponPool ?? []).map((w) => ({
|
||||
id: w.weaponSplId,
|
||||
isFavorite: Boolean(w.isFavorite),
|
||||
})),
|
||||
vc: matchProfile.vc ?? "NO",
|
||||
languages: matchProfile.languages ?? [],
|
||||
noScreen: Boolean(matchProfile.noScreen),
|
||||
}}
|
||||
revalidateRoot
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="mapModePreferences">
|
||||
{(props: {
|
||||
value: unknown;
|
||||
onChange: (value: UserMapModePreferences) => void;
|
||||
}) => (
|
||||
<MapModePreferencesField
|
||||
value={props.value as UserMapModePreferences}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="weaponPool" />
|
||||
<FormField name="vc" />
|
||||
<FormField name="languages" />
|
||||
<FormField name="noScreen" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
<div className="stack md">
|
||||
<LinkButton
|
||||
to={userCardEditPage({
|
||||
returnTo: `${location.pathname}${location.search}`,
|
||||
})}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<Pencil />}
|
||||
className="self-start"
|
||||
>
|
||||
{t("user:card.edit.title")}
|
||||
</LinkButton>
|
||||
<SendouForm
|
||||
schema={updateMatchProfileSchema}
|
||||
defaultValues={{
|
||||
mapModePreferences: preferencesFromRaw(
|
||||
matchProfile.mapModePreferences,
|
||||
),
|
||||
weaponPool: (matchProfile.weaponPool ?? []).map((w) => ({
|
||||
id: w.weaponSplId,
|
||||
isFavorite: Boolean(w.isFavorite),
|
||||
})),
|
||||
vc: matchProfile.vc ?? "NO",
|
||||
languages: matchProfile.languages ?? [],
|
||||
noScreen: Boolean(matchProfile.noScreen),
|
||||
}}
|
||||
revalidateRoot
|
||||
>
|
||||
{({ FormField }) => (
|
||||
<>
|
||||
<FormField name="mapModePreferences">
|
||||
{(props: {
|
||||
value: unknown;
|
||||
onChange: (value: UserMapModePreferences) => void;
|
||||
}) => (
|
||||
<MapModePreferencesField
|
||||
value={props.value as UserMapModePreferences}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="weaponPool" />
|
||||
<FormField name="vc" />
|
||||
<FormField name="languages" />
|
||||
<FormField name="noScreen" />
|
||||
</>
|
||||
)}
|
||||
</SendouForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import "./settings.global.css";
|
|||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["settings"],
|
||||
i18n: ["settings", "user"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("settings"),
|
||||
href: SETTINGS_PAGE,
|
||||
|
|
|
|||
|
|
@ -84,8 +84,37 @@ describe("refreshAllPeakXp", () => {
|
|||
.orderBy("id", "asc")
|
||||
.execute();
|
||||
|
||||
expect(players[0].peakXp).toBe(2700);
|
||||
expect(players[1].peakXp).toBe(3000);
|
||||
expect(players[0].peakXp).toEqual({
|
||||
overall: 2700,
|
||||
tentatek: 2700,
|
||||
takoroka: null,
|
||||
});
|
||||
expect(players[1].peakXp).toEqual({
|
||||
overall: 3000,
|
||||
tentatek: 3000,
|
||||
takoroka: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("splits peakXp by division (region)", async () => {
|
||||
const playerId = await createSplatoonPlayer("player1");
|
||||
|
||||
await createXRankPlacement({ playerId, power: 2700, region: "WEST" });
|
||||
await createXRankPlacement({ playerId, power: 2900, region: "JPN" });
|
||||
|
||||
await XRankPlacementRepository.refreshAllPeakXp();
|
||||
|
||||
const player = await db
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select("peakXp")
|
||||
.where("id", "=", playerId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(player.peakXp).toEqual({
|
||||
overall: 2900,
|
||||
tentatek: 2700,
|
||||
takoroka: 2900,
|
||||
});
|
||||
});
|
||||
|
||||
test("sets peakXp to null for player with no placements", async () => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,25 @@ export function unlinkPlayerByUserId(userId: number) {
|
|||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite expression extracting a Splatoon player's overall peak XP from the denormalized `peakXp`
|
||||
* JSON column (see {@link refreshAllPeakXp}). `"SplatoonPlayer"` must be in scope at the call site.
|
||||
*/
|
||||
export function peakXpOverallSql<T extends number | null = number | null>() {
|
||||
return sql<T>`"SplatoonPlayer"."peakXp" ->> '$.overall'`;
|
||||
}
|
||||
|
||||
/** Whether the user has a linked Splatoon player (i.e. has claimed their X Rank results). */
|
||||
export async function isPlayerLinkedByUserId(userId: number): Promise<boolean> {
|
||||
const player = await db
|
||||
.selectFrom("SplatoonPlayer")
|
||||
.select("SplatoonPlayer.id")
|
||||
.where("SplatoonPlayer.userId", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return Boolean(player);
|
||||
}
|
||||
|
||||
function xRankPlacementsQueryBase() {
|
||||
return db
|
||||
.selectFrom("XRankPlacement")
|
||||
|
|
@ -132,12 +151,23 @@ export type FindPlacement = InferResult<
|
|||
export async function refreshAllPeakXp() {
|
||||
await db
|
||||
.updateTable("SplatoonPlayer")
|
||||
.set((eb) => ({
|
||||
peakXp: eb
|
||||
.selectFrom("XRankPlacement")
|
||||
.select((eb) => eb.fn.max("XRankPlacement.power").as("peakXp"))
|
||||
.whereRef("XRankPlacement.playerId", "=", "SplatoonPlayer.id"),
|
||||
}))
|
||||
.set({
|
||||
// denormalized PeakXP json: overall + per-division peaks
|
||||
// (region WEST = Tentatek, otherwise Takoroka). null when no placements.
|
||||
peakXp: sql<string | null>`(
|
||||
select iif(
|
||||
max("XRankPlacement"."power") is null,
|
||||
null,
|
||||
json_object(
|
||||
'overall', max("XRankPlacement"."power"),
|
||||
'tentatek', max(iif("XRankPlacement"."region" = 'WEST', "XRankPlacement"."power", null)),
|
||||
'takoroka', max(iif("XRankPlacement"."region" != 'WEST', "XRankPlacement"."power", null))
|
||||
)
|
||||
)
|
||||
from "XRankPlacement"
|
||||
where "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
|
||||
)`,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-3);
|
||||
white-space: normal;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: var(--s-1) 0 0;
|
||||
padding-inline-start: var(--s-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.example {
|
||||
display: block;
|
||||
padding: var(--s-2);
|
||||
border-radius: var(--radius-box);
|
||||
background-color: var(--color-bg-higher);
|
||||
word-break: break-word;
|
||||
}
|
||||
51
app/features/top-search/components/HowToLinkPopover.tsx
Normal file
51
app/features/top-search/components/HowToLinkPopover.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SENDOU_INK_DISCORD_URL } from "~/utils/urls";
|
||||
import styles from "./HowToLinkPopover.module.css";
|
||||
|
||||
const EXAMPLE_PLAYER_URL = "https://sendou.ink/xsearch/player/0";
|
||||
|
||||
export function HowToLinkPopover() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton variant="minimal" size="small" className="self-start">
|
||||
{t("common:xsearch.link.trigger")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<div>
|
||||
<div className="font-bold">
|
||||
{t("common:xsearch.link.about.title")}
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
<li>{t("common:xsearch.link.about.endPower")}</li>
|
||||
<li>{t("common:xsearch.link.about.notPeak")}</li>
|
||||
<li>{t("common:xsearch.link.about.wait")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="stack sm">
|
||||
<div>
|
||||
<Trans t={t} i18nKey="common:xsearch.link.example">
|
||||
Post this message on our Discord (
|
||||
<a href={SENDOU_INK_DISCORD_URL} target="_blank" rel="noreferrer">
|
||||
discord.gg/sendou
|
||||
</a>
|
||||
), on the #helpdesk channel:
|
||||
</Trans>
|
||||
</div>
|
||||
<code className={styles.example}>
|
||||
{t("common:xsearch.link.exampleRequest")} {EXAMPLE_PLAYER_URL}
|
||||
</code>
|
||||
</div>
|
||||
<div className="text-lighter">
|
||||
{t("common:xsearch.link.noScreenshots")}
|
||||
</div>
|
||||
</div>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { action } from "../actions/xsearch.player.$id.server";
|
||||
import { HowToLinkPopover } from "../components/HowToLinkPopover";
|
||||
import { PlacementsTable } from "../components/Placements";
|
||||
import { loader } from "../loaders/xsearch.player.$id.server";
|
||||
|
||||
|
|
@ -77,19 +78,22 @@ export default function XSearchPlayerPage() {
|
|||
return (
|
||||
<Main halfWidth className="stack lg">
|
||||
<div>
|
||||
<h2 className="text-lg">
|
||||
{hasUserLinked(placementUser) ? (
|
||||
<Link to={userPage(placementUser)}>{data.names.primary}</Link>
|
||||
) : (
|
||||
data.names.primary
|
||||
)}{" "}
|
||||
{t("common:xsearch.placements")}
|
||||
</h2>
|
||||
{data.names.aliases.length > 0 ? (
|
||||
<div className="text-lighter text-sm">
|
||||
{t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h2 className="text-lg">
|
||||
{hasUserLinked(placementUser) ? (
|
||||
<Link to={userPage(placementUser)}>{data.names.primary}</Link>
|
||||
) : (
|
||||
data.names.primary
|
||||
)}{" "}
|
||||
{t("common:xsearch.placements")}
|
||||
</h2>
|
||||
{data.names.aliases.length > 0 ? (
|
||||
<div className="text-lighter text-sm">
|
||||
{t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!hasUserLinked(placementUser) ? <HowToLinkPopover /> : null}
|
||||
</div>
|
||||
<PlacementsTable placements={data.placements} type="MODE_INFO" />
|
||||
{isLinkedToCurrentUser ? <UnlinkFormWithButton /> : null}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { id: tournamentId } = parseParams({ params, schema: idObject });
|
||||
|
||||
const tournament = await tournamentFromDBCached({
|
||||
tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
|
||||
const userIds = R.unique(
|
||||
tournament.ctx.teams.flatMap((team) =>
|
||||
team.members.map((member) => member.userId),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
|
@ -176,15 +176,6 @@
|
|||
margin-left: var(--s-1);
|
||||
}
|
||||
|
||||
.plusTier {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.playerRemoved {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-high);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { CSS } from "@dnd-kit/utilities";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Link, useFetcher, useNavigation } from "react-router";
|
||||
import { useFetcher, useNavigation } from "react-router";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
|
|
@ -27,7 +27,6 @@ import {
|
|||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { Image } from "~/components/Image";
|
||||
import { InfoPopover } from "~/components/InfoPopover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { Table } from "~/components/Table";
|
||||
|
|
@ -37,12 +36,18 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
|||
import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { navIconUrl, userResultsPage } from "~/utils/urls";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
|
||||
import styles from "./to.$id.admin.seeds.module.css";
|
||||
|
||||
export { action } from "../actions/to.$id.admin.seeds.server";
|
||||
export { loader } from "../loaders/to.$id.admin.seeds.server";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user", "q"],
|
||||
};
|
||||
|
||||
const AB_DIVISION_RADIO_OPTIONS = [
|
||||
{ value: "unassigned", label: "Unassigned" },
|
||||
|
|
@ -755,20 +760,9 @@ function RowContents({
|
|||
[styles.playerNew]: isNew,
|
||||
})}
|
||||
>
|
||||
<Link to={userResultsPage(member, true)}>
|
||||
{member.username}
|
||||
</Link>
|
||||
{member.plusTier ? (
|
||||
<span className={styles.plusTier}>
|
||||
<Image
|
||||
path={navIconUrl("plus")}
|
||||
width={14}
|
||||
height={14}
|
||||
alt=""
|
||||
/>
|
||||
{member.plusTier}
|
||||
</span>
|
||||
) : null}
|
||||
<UserCard userId={member.userId}>
|
||||
<span>{member.username}</span>
|
||||
</UserCard>
|
||||
{isNew ? (
|
||||
<span className={styles.playerNewBadge}>NEW</span>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,28 @@ import { Mic, Star, Trash, Volume2, VolumeX } from "lucide-react";
|
|||
import * as React from "react";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher } from "react-router";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, WeaponImage } from "~/components/Image";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { Pronouns } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { navIconUrl, userPage } from "~/utils/urls";
|
||||
import { navIconUrl } from "~/utils/urls";
|
||||
import { updateGroupFormSchema } from "../tournament-lfg-schemas";
|
||||
import styles from "./LFGGroupCard.module.css";
|
||||
|
||||
|
|
@ -194,14 +199,23 @@ function LFGGroupMemberRow({
|
|||
showActions: boolean;
|
||||
isOwnGroup: boolean;
|
||||
}) {
|
||||
const cardData = useUserCardData(member.id);
|
||||
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
<div className={styles.member}>
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
<Avatar user={member} size="xs" />
|
||||
<Link to={userPage(member)} className={styles.name}>
|
||||
{member.username}
|
||||
</Link>
|
||||
<UserCard userId={member.id} withMutualFriends>
|
||||
<span className="stack xs horizontal items-center">
|
||||
<NoteAvatar
|
||||
sentiment={cardData?.privateNote?.sentiment}
|
||||
size="sm"
|
||||
>
|
||||
<Avatar user={member} size="xs" />
|
||||
</NoteAvatar>
|
||||
<span className={styles.name}>{member.username}</span>
|
||||
</span>
|
||||
</UserCard>
|
||||
{member.pronouns ? (
|
||||
<span className="text-lighter ml-1 text-xxxs">
|
||||
{member.pronouns.subject}/{member.pronouns.object}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import type { Pronouns } from "~/db/tables";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
|
|
@ -83,8 +85,16 @@ async function lookingMode({
|
|||
ownGroup,
|
||||
});
|
||||
|
||||
const cardUserIds = R.unique([
|
||||
...groups.flatMap((group) => group.members.map((member) => member.id)),
|
||||
...(ownTeam?.members ?? []).map((member) => member.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
mode: "looking" as const,
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: cardUserIds,
|
||||
})),
|
||||
groups: otherGroups,
|
||||
ownGroup,
|
||||
ownTeam,
|
||||
|
|
@ -130,6 +140,9 @@ async function subsMode({
|
|||
|
||||
return {
|
||||
mode: "subs" as const,
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: subs.map((sub) => sub.userId),
|
||||
})),
|
||||
subs,
|
||||
hasOwnSubPost: subs.some((sub) => sub.userId === user?.id),
|
||||
tournamentId,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Mic, Trash } from "lucide-react";
|
|||
import * as React from "react";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher, useLoaderData } from "react-router";
|
||||
import { useFetcher, useLoaderData } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
|
|
@ -16,16 +16,20 @@ import {
|
|||
} from "~/components/elements/Tabs";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
} from "~/features/user-card/components/UserCard";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import { LFGGroupCard } from "../components/LFGGroupCard";
|
||||
import {
|
||||
type LookingLoaderData,
|
||||
|
|
@ -43,7 +47,7 @@ export { loader };
|
|||
import styles from "./to.$id.looking.module.css";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q", "tournament", "forms", "common"],
|
||||
i18n: ["q", "tournament", "forms", "common", "user"],
|
||||
};
|
||||
|
||||
export default function TournamentLFGShell() {
|
||||
|
|
@ -285,6 +289,7 @@ function SubCard({ sub }: { sub: SubEntry }) {
|
|||
const { t } = useTranslation(["common", "tournament"]);
|
||||
const user = useUser();
|
||||
const tournament = useTournament();
|
||||
const cardData = useUserCardData(sub.userId);
|
||||
|
||||
const infos = [
|
||||
<div key="vc" className={styles.subsInfoVc}>
|
||||
|
|
@ -322,10 +327,18 @@ function SubCard({ sub }: { sub: SubEntry }) {
|
|||
return (
|
||||
<div>
|
||||
<section className={styles.subsSection}>
|
||||
<Avatar user={sub} size="sm" className={styles.subsSectionAvatar} />
|
||||
<Link to={userPage(sub)} className={styles.subsSectionName}>
|
||||
{sub.username}
|
||||
</Link>
|
||||
<div className={styles.subsSectionAvatar}>
|
||||
<UserCard userId={sub.userId} withMutualFriends>
|
||||
<NoteAvatar sentiment={cardData?.privateNote?.sentiment} size="sm">
|
||||
<Avatar user={sub} size="sm" />
|
||||
</NoteAvatar>
|
||||
</UserCard>
|
||||
</div>
|
||||
<div className={styles.subsSectionName}>
|
||||
<UserCard userId={sub.userId} withMutualFriends>
|
||||
<span>{sub.username}</span>
|
||||
</UserCard>
|
||||
</div>
|
||||
<div className={styles.subsSectionInfo}>{infos}</div>
|
||||
{sub.weapons ? (
|
||||
<div className={styles.subsSectionWeapons}>
|
||||
|
|
|
|||
|
|
@ -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,12 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
|||
!isLeagueRoundLocked(tournament, match.roundId);
|
||||
|
||||
return {
|
||||
...(await UserCardRepository.userCards({
|
||||
userIds: match.players.map((p) => p.id),
|
||||
include: {
|
||||
friendCode: isParticipant || isSiteStaff || isTournamentStaff,
|
||||
},
|
||||
})),
|
||||
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
|
||||
results,
|
||||
reportedWeapons,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -421,6 +421,39 @@ export async function findChildTournaments(parentTournamentId: number) {
|
|||
}));
|
||||
}
|
||||
|
||||
/** Child division tournaments of a league sign-up, with their name and finalized status. */
|
||||
export function findChildTournamentsForDivCalc(parentTournamentId: number) {
|
||||
return db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select([
|
||||
"Tournament.id as tournamentId",
|
||||
"CalendarEvent.name",
|
||||
"Tournament.isFinalized",
|
||||
])
|
||||
.where("Tournament.parentTournamentId", "=", parentTournamentId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* User ids eligible for a LUTI division placement in the given tournament: they have a result, were
|
||||
* on a team that did not drop out, and played at least one match.
|
||||
*/
|
||||
export function findLeagueDivParticipantUserIds(tournamentId: number) {
|
||||
return db
|
||||
.selectFrom("TournamentResult")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentResult.tournamentTeamId",
|
||||
)
|
||||
.select("TournamentResult.userId")
|
||||
.distinct()
|
||||
.where("TournamentResult.tournamentId", "=", tournamentId)
|
||||
.where("TournamentTeam.droppedOut", "=", 0)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function findTOSetMapPoolById(tournamentId: number) {
|
||||
return (
|
||||
await db
|
||||
|
|
|
|||
161
app/features/user-card/UserCardRepository.server.test.ts
Normal file
161
app/features/user-card/UserCardRepository.server.test.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import { dbInsertUsers, dbReset, withNoUser, withUserId } from "~/utils/Test";
|
||||
import * as UserCardRepository from "./UserCardRepository.server";
|
||||
|
||||
describe("UserCardRepository.userCards", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(2);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
it("returns an empty map when given no user ids", async () => {
|
||||
const { userCards } = await withNoUser(() =>
|
||||
UserCardRepository.userCards({
|
||||
userIds: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(userCards.size).toBe(0);
|
||||
});
|
||||
|
||||
it("keys cards by user id and builds the stats array from db fields", async () => {
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
div: "1",
|
||||
unverifiedPeakXP: JSON.stringify({
|
||||
overall: 3000,
|
||||
takoroka: 3000,
|
||||
tentatek: null,
|
||||
}),
|
||||
})
|
||||
.where("id", "=", 1)
|
||||
.execute();
|
||||
await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
|
||||
|
||||
const { userCards } = await withNoUser(() =>
|
||||
UserCardRepository.userCards({
|
||||
userIds: [1, 2],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(userCards.size).toBe(2);
|
||||
|
||||
const card = userCards.get(1);
|
||||
expect(card?.id).toBe(1);
|
||||
expect(card?.freeAgentPostId).toBeNull();
|
||||
|
||||
const statTypes = card?.stats.map((stat) => stat.type) ?? [];
|
||||
expect(statTypes).toContain("XP");
|
||||
expect(statTypes).toContain("DIV");
|
||||
expect(statTypes).toContain("PLUS");
|
||||
|
||||
expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
|
||||
type: "XP",
|
||||
values: [{ isVerified: false, region: "JPN", points: 3000 }],
|
||||
});
|
||||
expect(card?.stats.find((stat) => stat.type === "DIV")).toMatchObject({
|
||||
type: "DIV",
|
||||
value: "1",
|
||||
});
|
||||
expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
|
||||
type: "PLUS",
|
||||
value: 2,
|
||||
});
|
||||
|
||||
// user 2 has none of the optional fields -> no stats
|
||||
expect(userCards.get(2)?.stats).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("persists edited card fields and surfaces hidden stats", async () => {
|
||||
await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
|
||||
|
||||
await withUserId(1, () =>
|
||||
UserCardRepository.updateOwnCard({
|
||||
shortBio: "hello",
|
||||
bannerPresetImg: "#ff4655",
|
||||
bannerImgId: null,
|
||||
unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 },
|
||||
hiddenCardStats: ["XP"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { userCards } = await withUserId(1, () =>
|
||||
UserCardRepository.userCards({
|
||||
userIds: [1],
|
||||
}),
|
||||
);
|
||||
const card = userCards.get(1);
|
||||
|
||||
expect(card?.shortBio).toBe("hello");
|
||||
expect(card?.banner).toMatchObject({ type: "COLOR", hexCode: "#ff4655" });
|
||||
// the hidden stat is filtered out of `stats` at query time
|
||||
expect(card?.stats.find((stat) => stat.type === "XP")).toBeUndefined();
|
||||
expect(card?.stats.find((stat) => stat.type === "PLUS")).toMatchObject({
|
||||
type: "PLUS",
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const extras = await UserCardRepository.cardEditExtras(1);
|
||||
expect(extras.hiddenCardStats).toEqual(["XP"]);
|
||||
});
|
||||
|
||||
it("keeps hidden stats in `stats` when includeHiddenStats is set", async () => {
|
||||
await db.insertInto("PlusTier").values({ userId: 1, tier: 2 }).execute();
|
||||
|
||||
await withUserId(1, () =>
|
||||
UserCardRepository.updateOwnCard({
|
||||
shortBio: null,
|
||||
bannerPresetImg: null,
|
||||
bannerImgId: null,
|
||||
unverifiedPeakXP: { overall: 2500, takoroka: null, tentatek: 2500 },
|
||||
hiddenCardStats: ["XP"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { userCards } = await withUserId(1, () =>
|
||||
UserCardRepository.userCards({
|
||||
userIds: [1],
|
||||
includeHiddenStats: true,
|
||||
}),
|
||||
);
|
||||
const card = userCards.get(1);
|
||||
|
||||
expect(card?.stats.find((stat) => stat.type === "XP")).toMatchObject({
|
||||
type: "XP",
|
||||
values: [{ isVerified: false, region: "WEST", points: 2500 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("produces a URL banner when an uploaded banner image is set", async () => {
|
||||
const image = await db
|
||||
.insertInto("UnvalidatedUserSubmittedImage")
|
||||
.values({ url: "banner.webp", submitterUserId: 1, validatedAt: 1 })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await withUserId(1, () =>
|
||||
UserCardRepository.updateOwnCard({
|
||||
shortBio: null,
|
||||
bannerPresetImg: null,
|
||||
bannerImgId: image.id,
|
||||
unverifiedPeakXP: null,
|
||||
hiddenCardStats: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const { userCards } = await withUserId(1, () =>
|
||||
UserCardRepository.userCards({
|
||||
userIds: [1],
|
||||
}),
|
||||
);
|
||||
|
||||
const banner = userCards.get(1)?.banner;
|
||||
expect(banner?.type).toBe("URL");
|
||||
expect(banner).toHaveProperty("url");
|
||||
});
|
||||
});
|
||||
563
app/features/user-card/UserCardRepository.server.ts
Normal file
563
app/features/user-card/UserCardRepository.server.ts
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
import { sub } from "date-fns";
|
||||
import type { Expression, ExpressionBuilder } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import { jsonBuildObject, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
CustomTheme,
|
||||
HideableUserCardStat,
|
||||
PeakXP,
|
||||
Tables,
|
||||
XRankPlacementRegion,
|
||||
} from "~/db/tables";
|
||||
import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
|
||||
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import { LFG } from "~/features/lfg/lfg-constants";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import {
|
||||
commonUserObjectFields,
|
||||
concatUserSubmittedImagePrefix,
|
||||
} from "~/utils/kysely.server";
|
||||
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
|
||||
import type {
|
||||
UserCardData,
|
||||
UserCardStat,
|
||||
UserCardStatXPValue,
|
||||
} from "./user-card-types";
|
||||
|
||||
/**
|
||||
* Loads `UserCardData` for many users at once, keyed by user id. The single batched DB query (see
|
||||
* {@link userCardDataJsonObject}) is merged with the in-memory SEASON caches (tier from
|
||||
* `userSkills`, leaderboard placement from `cachedFullUserLeaderboard`) in this app-layer enrich
|
||||
* pass, producing the fully-formed `stats` array each card renders. The acting user viewing the
|
||||
* cards (resolved from request context via `actorIdOrNull()`, or `null` when anonymous) scopes the
|
||||
* per-viewer `privateNote`.
|
||||
*
|
||||
* Designed to be spread into a route loader (`{ ...(await userCards(...)) }`) so the `UserCard`
|
||||
* component can resolve its own data from the route tree by id.
|
||||
*/
|
||||
export async function userCards({
|
||||
userIds,
|
||||
include,
|
||||
includeHiddenStats = false,
|
||||
}: {
|
||||
userIds: Array<number>;
|
||||
/** Opt-in fields skipped from the query by default; defaults to `false` each. */
|
||||
include?: { friendCode?: boolean };
|
||||
/**
|
||||
* Keep stats the user has hidden in the resolved `stats` array. Off by default so hidden stat
|
||||
* values never reach a viewer; the edit page opts in to render (and un-hide) its own toggles.
|
||||
*/
|
||||
includeHiddenStats?: boolean;
|
||||
}): Promise<{ userCards: Map<number, UserCardData> }> {
|
||||
if (userIds.length === 0) return { userCards: new Map() };
|
||||
|
||||
const viewerId = actorIdOrNull();
|
||||
|
||||
// a user's card surfaces the better of their last two finished seasons (see bestSeasonResult)
|
||||
const [rows, seasonResults] = await Promise.all([
|
||||
db
|
||||
.selectFrom("User")
|
||||
.select((eb) =>
|
||||
userCardDataJsonObject(eb, { viewerId, include }).as("cardData"),
|
||||
)
|
||||
.where("User.id", "in", userIds)
|
||||
.execute(),
|
||||
Promise.all(
|
||||
Seasons.allFinished()
|
||||
.slice(0, 2)
|
||||
.map((season) => seasonResult(season, userIds)),
|
||||
),
|
||||
]);
|
||||
|
||||
const userCards = new Map<number, UserCardData>();
|
||||
for (const { cardData } of rows) {
|
||||
userCards.set(
|
||||
cardData.id,
|
||||
enrichUserCardData(
|
||||
cardData,
|
||||
bestSeasonResult(cardData.id, seasonResults),
|
||||
includeHiddenStats,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return { userCards };
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw card fields the edit form needs that are not part of {@link UserCardData}: the uploaded banner
|
||||
* image (id + preview url, for the image field's default value), the self-reported peak XP, and the
|
||||
* hidden stat types (to pre-check the visibility toggles).
|
||||
*/
|
||||
export async function cardEditExtras(userId: number) {
|
||||
const row = await db
|
||||
.selectFrom("User")
|
||||
.select((eb) => [
|
||||
"User.bannerImgId",
|
||||
"User.unverifiedPeakXP",
|
||||
"User.hiddenCardStats",
|
||||
bannerImageUrl(eb).as("bannerImageUrl"),
|
||||
])
|
||||
.where("User.id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return {
|
||||
bannerImgId: row?.bannerImgId ?? null,
|
||||
bannerImageUrl: row?.bannerImageUrl ?? null,
|
||||
unverifiedPeakXP: row?.unverifiedPeakXP ?? null,
|
||||
hiddenCardStats: row?.hiddenCardStats ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates the editable user card fields of the acting user (their own card). */
|
||||
export function updateOwnCard(args: {
|
||||
shortBio: string | null;
|
||||
bannerPresetImg: string | null;
|
||||
bannerImgId: number | null;
|
||||
unverifiedPeakXP: PeakXP | null;
|
||||
hiddenCardStats: Array<HideableUserCardStat>;
|
||||
}) {
|
||||
const userId = actorId();
|
||||
return db.transaction().execute(async (trx) => {
|
||||
// a removed or replaced uploaded banner is no longer referenced by anything,
|
||||
// so its submitted image row is cleaned up (mirrors custom avatar handling)
|
||||
const current = await trx
|
||||
.selectFrom("User")
|
||||
.select("User.bannerImgId")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (current?.bannerImgId && current.bannerImgId !== args.bannerImgId) {
|
||||
await trx
|
||||
.deleteFrom("UnvalidatedUserSubmittedImage")
|
||||
.where("id", "=", current.bannerImgId)
|
||||
.where("UnvalidatedUserSubmittedImage.submitterUserId", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set({
|
||||
shortBio: args.shortBio,
|
||||
bannerPresetImg: args.bannerPresetImg,
|
||||
bannerImgId: args.bannerImgId,
|
||||
unverifiedPeakXP: args.unverifiedPeakXP
|
||||
? JSON.stringify(args.unverifiedPeakXP)
|
||||
: null,
|
||||
hiddenCardStats:
|
||||
args.hiddenCardStats.length > 0
|
||||
? JSON.stringify(args.hiddenCardStats)
|
||||
: null,
|
||||
})
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
/** SQLite `case` expression mapping `User.id % PRESET_COLORS.length` to a preset banner color. */
|
||||
const BANNER_PRESET_COLOR_CASE = `case "User"."id" % ${PRESET_COLORS.length}\n${PRESET_COLORS.map(
|
||||
(color, index) => `when ${index} then '${color}'`,
|
||||
).join("\n")}\nend`;
|
||||
|
||||
/**
|
||||
* Kysely expression building the JSON object for all DB-resident `UserCard` fields of a single user.
|
||||
* Designed to be composed both standalone (one user) and inside a batched list query (see
|
||||
* {@link userCards}). `"User"` must be in scope at the call site.
|
||||
*
|
||||
* SEASON stats (tier + leaderboard placement) are NOT included here — they live in the in-memory
|
||||
* `userSkills`/leaderboard caches and are merged in an app-layer enrich pass. `banner` is returned as
|
||||
* loosely-typed fields (narrow to the discriminated union there). `friendCode` is opt-in via
|
||||
* `include.friendCode` (defaults to off, resolving to `null`) so callers that never surface it skip
|
||||
* the extra correlated subquery.
|
||||
*/
|
||||
function userCardDataJsonObject(
|
||||
eb: ExpressionBuilder<Tables, "User">,
|
||||
{
|
||||
viewerId,
|
||||
include,
|
||||
}: {
|
||||
viewerId: number | null;
|
||||
include?: { friendCode?: boolean };
|
||||
},
|
||||
) {
|
||||
return jsonBuildObject({
|
||||
...commonUserObjectFields(eb),
|
||||
shortBio: eb.ref("User.shortBio"),
|
||||
div: eb.ref("User.div"),
|
||||
customTheme: sql<CustomTheme | null>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."customTheme", null)`,
|
||||
hiddenCardStats: eb.ref("User.hiddenCardStats"),
|
||||
banner: bannerJson(eb),
|
||||
friendCode: include?.friendCode
|
||||
? friendCodeScalar(eb)
|
||||
: sql<string | null>`null`,
|
||||
privateNote: privateNoteJson(eb, viewerId),
|
||||
freeAgentPostId: freeAgentPostIdScalar(eb),
|
||||
plusTier: plusTierScalar(eb),
|
||||
xpVerified: xpVerifiedJson(eb),
|
||||
xpUnverified: xpUnverifiedJson(),
|
||||
});
|
||||
}
|
||||
|
||||
type RawUserCardData =
|
||||
ReturnType<typeof userCardDataJsonObject> extends Expression<infer T>
|
||||
? T
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Loosely-typed banner. A supporter-uploaded image (`User.bannerImgId`) takes precedence and yields
|
||||
* a `URL` banner; otherwise it is pulled from the `User.bannerPresetImg` column ("hex code or stage
|
||||
* id") where a numeric value is a stage id (`STAGE`) and anything else a `COLOR` hex code. When both
|
||||
* are null (no explicit choice) a preset color is derived from the user id. Narrow to the
|
||||
* `{ URL | COLOR | STAGE }` union in the enrich pass.
|
||||
*/
|
||||
function bannerJson(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject({
|
||||
type: sql<"URL" | "COLOR" | "STAGE">`
|
||||
case
|
||||
when "User"."bannerImgId" is not null then 'URL'
|
||||
when "User"."bannerPresetImg" GLOB '[0-9]*' then 'STAGE'
|
||||
else 'COLOR'
|
||||
end`,
|
||||
url: bannerImageUrl(eb),
|
||||
hexCode: sql<string | null>`
|
||||
case
|
||||
when "User"."bannerPresetImg" is null then (${sql.raw(BANNER_PRESET_COLOR_CASE)})
|
||||
when "User"."bannerPresetImg" GLOB '[0-9]*' then null
|
||||
else "User"."bannerPresetImg"
|
||||
end`,
|
||||
stageId: sql<
|
||||
number | null
|
||||
>`iif("User"."bannerPresetImg" GLOB '[0-9]*', CAST("User"."bannerPresetImg" AS INTEGER), null)`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Full URL of the supporter-uploaded banner image (resolved from `User.bannerImgId`), or null. */
|
||||
function bannerImageUrl(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return concatUserSubmittedImagePrefix(
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select("UserSubmittedImage.url")
|
||||
.whereRef("UserSubmittedImage.id", "=", "User.bannerImgId")
|
||||
.$asScalar(),
|
||||
).$castTo<string | null>();
|
||||
}
|
||||
|
||||
function friendCodeScalar(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return eb
|
||||
.selectFrom("UserFriendCode")
|
||||
.select("UserFriendCode.friendCode")
|
||||
.whereRef("UserFriendCode.userId", "=", "User.id")
|
||||
.orderBy("UserFriendCode.createdAt", "desc")
|
||||
.limit(1)
|
||||
.$asScalar();
|
||||
}
|
||||
|
||||
function privateNoteJson(
|
||||
eb: ExpressionBuilder<Tables, "User">,
|
||||
viewerId: number | null,
|
||||
) {
|
||||
if (viewerId === null) {
|
||||
return sql<Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"text" | "sentiment" | "updatedAt"
|
||||
> | null>`null`;
|
||||
}
|
||||
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("PrivateUserNote.authorId", "=", viewerId)
|
||||
.whereRef("PrivateUserNote.targetId", "=", "User.id"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the user's most recent non-expired "looking for team" LFG post, which marks them as a free
|
||||
* agent. `null` when they have no such post. Mirrors the LFG page's freshness cutoff so the id always
|
||||
* points at a post that is still listed there.
|
||||
*/
|
||||
function freeAgentPostIdScalar(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return eb
|
||||
.selectFrom("LFGPost")
|
||||
.select("LFGPost.id")
|
||||
.whereRef("LFGPost.authorId", "=", "User.id")
|
||||
.where("LFGPost.type", "=", "PLAYER_FOR_TEAM")
|
||||
.where(
|
||||
"LFGPost.updatedAt",
|
||||
">",
|
||||
dateToDatabaseTimestamp(
|
||||
sub(new Date(), { days: LFG.POST_FRESHNESS_DAYS }),
|
||||
),
|
||||
)
|
||||
.orderBy("LFGPost.updatedAt", "desc")
|
||||
.limit(1)
|
||||
.$asScalar();
|
||||
}
|
||||
|
||||
function plusTierScalar(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return eb
|
||||
.selectFrom("PlusTier")
|
||||
.select("PlusTier.tier")
|
||||
.whereRef("PlusTier.userId", "=", "User.id")
|
||||
.$asScalar();
|
||||
}
|
||||
|
||||
/** Single highest X Rank power placement (verified XP). */
|
||||
function xpVerifiedJson(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("XRankPlacement")
|
||||
.innerJoin(
|
||||
"SplatoonPlayer",
|
||||
"SplatoonPlayer.id",
|
||||
"XRankPlacement.playerId",
|
||||
)
|
||||
.whereRef("SplatoonPlayer.userId", "=", "User.id")
|
||||
.select([
|
||||
sql<number>`"XRankPlacement"."power"`.as("points"),
|
||||
"XRankPlacement.region",
|
||||
])
|
||||
.orderBy("XRankPlacement.power", "desc")
|
||||
.limit(1),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-reported peak XP from the `User.unverifiedPeakXP` column. Has exactly one of `tentatek` /
|
||||
* `takoroka` defined, which decides the region (`tentatek` = `WEST`, `takoroka` = `JPN`); `points`
|
||||
* is that region's value.
|
||||
*/
|
||||
function xpUnverifiedJson() {
|
||||
return sql<{ points: number; region: XRankPlacementRegion } | null>`
|
||||
iif(
|
||||
"User"."unverifiedPeakXP" is null,
|
||||
null,
|
||||
json_object(
|
||||
'points', "User"."unverifiedPeakXP" ->> '$.overall',
|
||||
'region', iif("User"."unverifiedPeakXP" ->> '$.tentatek' is not null, 'WEST', 'JPN')
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
type SeasonResult = {
|
||||
skills: Record<string, TieredSkill>;
|
||||
placementsByUserId: Map<number, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves one finished season's data for the requested users. `userSkills` is a synchronous
|
||||
* in-memory cache, so we read tiers first and only fetch the (DB-backed) leaderboard when at least
|
||||
* one requested user reached Leviathan+ that season—placements are surfaced for that rank only, so
|
||||
* the common case of regular users never touches the leaderboard cache at all.
|
||||
*/
|
||||
async function seasonResult(
|
||||
season: number,
|
||||
userIds: Array<number>,
|
||||
): Promise<SeasonResult> {
|
||||
const skills = userSkills(season).userSkills;
|
||||
|
||||
const anyLeviathanPlus = userIds.some((id) => {
|
||||
const skill = skills[id];
|
||||
return (
|
||||
skill !== undefined && !skill.approximate && isLeviathanPlus(skill.tier)
|
||||
);
|
||||
});
|
||||
|
||||
const placementsByUserId = anyLeviathanPlus
|
||||
? await finishedSeasonPlacements(season)
|
||||
: new Map<number, number>();
|
||||
|
||||
return { skills, placementsByUserId };
|
||||
}
|
||||
|
||||
const placementsBySeason = new Map<number, Map<number, number>>();
|
||||
|
||||
/**
|
||||
* Leaderboard placements of a finished season, keyed by user id. Cached for the process lifetime:
|
||||
* a finished season's leaderboard is immutable, matching how `userSkills` already holds finished
|
||||
* seasons' tiers permanently. This keeps cards off `cachedFullUserLeaderboard`'s TTL, whose
|
||||
* synchronous rebuild would otherwise stall the first Leviathan+ card render after a quiet period.
|
||||
*/
|
||||
async function finishedSeasonPlacements(
|
||||
season: number,
|
||||
): Promise<Map<number, number>> {
|
||||
const cached = placementsBySeason.get(season);
|
||||
if (cached) return cached;
|
||||
|
||||
const placements = new Map(
|
||||
(await cachedFullUserLeaderboard(season)).map((entry) => [
|
||||
entry.id,
|
||||
entry.placementRank,
|
||||
]),
|
||||
);
|
||||
placementsBySeason.set(season, placements);
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
||||
const isLeviathanPlus = (tier: TieredSkill["tier"]) =>
|
||||
tier.name === "LEVIATHAN" && tier.isPlus;
|
||||
|
||||
/**
|
||||
* Comparable strength of a tier (higher = better). Based on the tier's position in `TIERS` rather
|
||||
* than the raw ordinal, because each season sets its own ordinal thresholds—the same ordinal can
|
||||
* map to different tiers across seasons—so only the tier itself is comparable. `isPlus` (top half of
|
||||
* a tier) breaks ties within the same tier name.
|
||||
*/
|
||||
const tierStrength = (tier: TieredSkill["tier"]) => {
|
||||
const index = TIERS.findIndex((t) => t.name === tier.name);
|
||||
return (TIERS.length - index) * 2 + (tier.isPlus ? 1 : 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces a user's results across the last two finished seasons into the single result their card
|
||||
* shows: the highest tier they reached (ignoring `approximate` tiers, which lack enough matches to
|
||||
* count), and—only when that includes the very top Leviathan+ rank—their best (lowest) leaderboard
|
||||
* placement among the Leviathan+ seasons.
|
||||
*/
|
||||
function bestSeasonResult(
|
||||
userId: number,
|
||||
seasonResults: Array<SeasonResult>,
|
||||
): { seasonSkill: TieredSkill | undefined; seasonTop: number | null } {
|
||||
let seasonSkill: TieredSkill | undefined;
|
||||
let seasonTop: number | null = null;
|
||||
|
||||
for (const { skills, placementsByUserId } of seasonResults) {
|
||||
const skill = skills[userId];
|
||||
if (!skill || skill.approximate) continue;
|
||||
|
||||
if (
|
||||
!seasonSkill ||
|
||||
tierStrength(skill.tier) > tierStrength(seasonSkill.tier)
|
||||
) {
|
||||
seasonSkill = skill;
|
||||
}
|
||||
|
||||
if (isLeviathanPlus(skill.tier)) {
|
||||
const placement = placementsByUserId.get(userId);
|
||||
if (
|
||||
typeof placement === "number" &&
|
||||
(seasonTop === null || placement < seasonTop)
|
||||
) {
|
||||
seasonTop = placement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { seasonSkill, seasonTop };
|
||||
}
|
||||
|
||||
function enrichUserCardData(
|
||||
cardData: RawUserCardData,
|
||||
{
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
}: { seasonSkill: TieredSkill | undefined; seasonTop: number | null },
|
||||
includeHiddenStats: boolean,
|
||||
): UserCardData {
|
||||
const hiddenStats: Array<UserCardStat["type"]> =
|
||||
cardData.hiddenCardStats ?? [];
|
||||
|
||||
const stats = userCardStats({
|
||||
div: cardData.div,
|
||||
plusTier: cardData.plusTier,
|
||||
xpVerified: cardData.xpVerified,
|
||||
xpUnverified: cardData.xpUnverified,
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
});
|
||||
|
||||
return {
|
||||
id: cardData.id,
|
||||
username: cardData.username,
|
||||
discordId: cardData.discordId,
|
||||
discordAvatar: cardData.discordAvatar,
|
||||
customUrl: cardData.customUrl,
|
||||
customAvatarUrl: cardData.customAvatarUrl,
|
||||
shortBio: cardData.shortBio,
|
||||
customTheme: cardData.customTheme,
|
||||
banner: enrichBanner(cardData.banner),
|
||||
friendCode: cardData.friendCode,
|
||||
freeAgentPostId: cardData.freeAgentPostId,
|
||||
privateNote: cardData.privateNote,
|
||||
stats: includeHiddenStats
|
||||
? stats
|
||||
: stats.filter((stat) => !hiddenStats.includes(stat.type)),
|
||||
};
|
||||
}
|
||||
|
||||
function enrichBanner(
|
||||
banner: RawUserCardData["banner"],
|
||||
): UserCardData["banner"] {
|
||||
if (banner.type === "URL" && banner.url) {
|
||||
return { type: "URL", url: banner.url };
|
||||
}
|
||||
|
||||
if (banner.type === "STAGE") {
|
||||
return { type: "STAGE", stageId: banner.stageId as StageId };
|
||||
}
|
||||
|
||||
return { type: "COLOR", hexCode: banner.hexCode ?? "" };
|
||||
}
|
||||
|
||||
function userCardStats({
|
||||
div,
|
||||
plusTier,
|
||||
xpVerified,
|
||||
xpUnverified,
|
||||
seasonSkill,
|
||||
seasonTop,
|
||||
}: {
|
||||
div: string | null;
|
||||
plusTier: number | null;
|
||||
xpVerified: { points: number; region: XRankPlacementRegion } | null;
|
||||
xpUnverified: { points: number; region: XRankPlacementRegion } | null;
|
||||
seasonSkill: TieredSkill | undefined;
|
||||
seasonTop: number | null;
|
||||
}): Array<UserCardStat> {
|
||||
const stats: Array<UserCardStat> = [];
|
||||
|
||||
const xpValues: Array<UserCardStatXPValue> = [];
|
||||
if (xpUnverified) {
|
||||
xpValues.push({
|
||||
isVerified: false,
|
||||
region: xpUnverified.region,
|
||||
points: xpUnverified.points,
|
||||
});
|
||||
}
|
||||
if (xpVerified) {
|
||||
xpValues.push({
|
||||
isVerified: true,
|
||||
region: xpVerified.region,
|
||||
points: xpVerified.points,
|
||||
});
|
||||
}
|
||||
if (xpValues.length > 0) {
|
||||
stats.push({ type: "XP", values: xpValues });
|
||||
}
|
||||
|
||||
if (seasonSkill && !seasonSkill.approximate) {
|
||||
stats.push({ type: "SEASON", value: seasonSkill.tier, top: seasonTop });
|
||||
}
|
||||
|
||||
if (typeof plusTier === "number") {
|
||||
stats.push({ type: "PLUS", value: plusTier });
|
||||
}
|
||||
|
||||
if (div) {
|
||||
stats.push({ type: "DIV", value: div });
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
108
app/features/user-card/actions/user-card.edit.server.ts
Normal file
108
app/features/user-card/actions/user-card.edit.server.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { type ActionFunction, redirect } from "react-router";
|
||||
import type { HideableUserCardStat } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import * as UserCardRepository from "../UserCardRepository.server";
|
||||
import { updateUserCardSchema } from "../user-card-schemas";
|
||||
import { maxUnverifiedXp } from "../user-card-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
|
||||
const returnTo = safeReturnTo(
|
||||
new URL(request.url).searchParams.get("returnTo"),
|
||||
);
|
||||
|
||||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
schema: updateUserCardSchema,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { fieldErrors: result.fieldErrors };
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
|
||||
if (data.unverifiedXpPoints) {
|
||||
const hasLinkedPlayer =
|
||||
await XRankPlacementRepository.isPlayerLinkedByUserId(user.id);
|
||||
const max = maxUnverifiedXp({
|
||||
division: data.unverifiedXpDivision,
|
||||
hasLinkedPlayer,
|
||||
});
|
||||
if (data.unverifiedXpPoints > max) {
|
||||
return {
|
||||
fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const isSupporter = Boolean(user.roles?.includes("SUPPORTER"));
|
||||
|
||||
await UserCardRepository.updateOwnCard({
|
||||
shortBio: data.shortBio || null,
|
||||
...resolveBanner({ ...data, isSupporter }),
|
||||
unverifiedPeakXP: data.unverifiedXpPoints
|
||||
? {
|
||||
overall: data.unverifiedXpPoints,
|
||||
tentatek:
|
||||
data.unverifiedXpDivision === "WEST"
|
||||
? data.unverifiedXpPoints
|
||||
: null,
|
||||
takoroka:
|
||||
data.unverifiedXpDivision === "JPN"
|
||||
? data.unverifiedXpPoints
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
hiddenCardStats: resolveHiddenStats(data),
|
||||
});
|
||||
|
||||
throw redirect(returnTo ?? userPage(user));
|
||||
};
|
||||
|
||||
function safeReturnTo(value: string | null) {
|
||||
if (!value) return null;
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveBanner({
|
||||
bannerType,
|
||||
bannerColor,
|
||||
bannerStageId,
|
||||
bannerImage,
|
||||
isSupporter,
|
||||
}: {
|
||||
bannerType: "COLOR" | "STAGE" | "URL";
|
||||
bannerColor: string;
|
||||
bannerStageId: number;
|
||||
bannerImage: number | null;
|
||||
isSupporter: boolean;
|
||||
}): { bannerPresetImg: string | null; bannerImgId: number | null } {
|
||||
switch (bannerType) {
|
||||
case "STAGE":
|
||||
return { bannerPresetImg: String(bannerStageId), bannerImgId: null };
|
||||
case "URL":
|
||||
return {
|
||||
bannerPresetImg: null,
|
||||
bannerImgId: isSupporter ? bannerImage : null,
|
||||
};
|
||||
default:
|
||||
return { bannerPresetImg: bannerColor, bannerImgId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHiddenStats(data: {
|
||||
hideXp: boolean;
|
||||
hideDiv: boolean;
|
||||
}): Array<HideableUserCardStat> {
|
||||
return [
|
||||
data.hideXp ? ("XP" as const) : null,
|
||||
data.hideDiv ? ("DIV" as const) : null,
|
||||
].filter((stat) => stat !== null);
|
||||
}
|
||||
|
|
@ -6,37 +6,57 @@ import { FormMessage } from "~/components/FormMessage";
|
|||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { preferenceEmojiUrl } from "~/utils/urls";
|
||||
import { preferenceEmojiUrl, userCardNotePage } from "~/utils/urls";
|
||||
|
||||
type PrivateNote = Pick<Tables["PrivateUserNote"], "text" | "sentiment">;
|
||||
|
||||
/**
|
||||
* Modal for adding/editing the viewer's private note about a user, posting to the
|
||||
* `/user-card/:id/note` resource route. Closes once the fetcher settles (save succeeds →
|
||||
* automatic revalidation refreshes the card). Clearing the text with a neutral sentiment and saving
|
||||
* deletes the note (handled by the route). Rendered wherever a `UserCard` lives.
|
||||
*/
|
||||
export function AddPrivateNoteDialog({
|
||||
aboutUser,
|
||||
close,
|
||||
userId,
|
||||
username,
|
||||
note,
|
||||
onClose,
|
||||
}: {
|
||||
aboutUser?: Pick<
|
||||
SQMatchGroup["members"][number],
|
||||
"id" | "username" | "privateNote"
|
||||
>;
|
||||
close: () => void;
|
||||
userId: number;
|
||||
username: string;
|
||||
note: PrivateNote | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
if (!aboutUser) return null;
|
||||
const wasSubmittingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (fetcher.state !== "idle") {
|
||||
wasSubmittingRef.current = true;
|
||||
} else if (wasSubmittingRef.current) {
|
||||
wasSubmittingRef.current = false;
|
||||
if ((fetcher.data as { ok?: boolean } | undefined)?.ok) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, onClose]);
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
isOpen
|
||||
heading={t("q:privateNote.header", { name: aboutUser.username })}
|
||||
onClose={close}
|
||||
heading={t("q:privateNote.header", { name: username })}
|
||||
onClose={onClose}
|
||||
>
|
||||
<fetcher.Form method="post" className="stack md">
|
||||
<input type="hidden" name="targetId" value={aboutUser.id} />
|
||||
<Textarea initialValue={aboutUser.privateNote?.text} />
|
||||
<Sentiment initialValue={aboutUser.privateNote?.sentiment} />
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={userCardNotePage(userId)}
|
||||
className="stack md"
|
||||
>
|
||||
<Textarea initialValue={note?.text} />
|
||||
<Sentiment initialValue={note?.sentiment} />
|
||||
<div className="stack items-center mt-2">
|
||||
<SubmitButton _action="ADD_PRIVATE_USER_NOTE">
|
||||
<SubmitButton _action="SAVE" state={fetcher.state}>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
|
|
@ -105,9 +125,9 @@ function Textarea({ initialValue }: { initialValue?: string | null }) {
|
|||
const [value, setValue] = React.useState(initialValue ?? "");
|
||||
|
||||
return (
|
||||
<div className="u-edit__bio-container">
|
||||
<div className="stack">
|
||||
<Label
|
||||
htmlFor="text"
|
||||
htmlFor="comment"
|
||||
valueLimits={{
|
||||
current: value.length,
|
||||
max: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
|
||||
|
|
@ -116,7 +136,7 @@ function Textarea({ initialValue }: { initialValue?: string | null }) {
|
|||
{t("q:privateNote.comment.header")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="text"
|
||||
id="comment"
|
||||
name="comment"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
247
app/features/user-card/components/UserCard.module.css
Normal file
247
app/features/user-card/components/UserCard.module.css
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
.trigger {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.popover {
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-5);
|
||||
width: 18rem;
|
||||
max-width: calc(100vw - var(--s-4));
|
||||
padding: 0 var(--s-4) var(--s-4);
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-box);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner {
|
||||
height: 6rem;
|
||||
margin-inline: calc(-1 * var(--s-4));
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--color-bg-higher);
|
||||
}
|
||||
|
||||
.iconButtons {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
inset-inline-end: var(--s-2);
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.freeAgentBadge {
|
||||
position: absolute;
|
||||
top: var(--s-2);
|
||||
inset-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2-5);
|
||||
margin-top: calc(-1 * (var(--s-8)));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 3px solid var(--color-bg);
|
||||
margin-block-end: -3px;
|
||||
}
|
||||
|
||||
.nameGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: var(--s-6);
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
inset-inline-start: 92px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1.1;
|
||||
max-width: 170px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
max-width: 170px;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.friendCode {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2-5);
|
||||
font-weight: var(--weight-bold);
|
||||
}
|
||||
|
||||
.stat {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.plusStat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.xpStat {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.xpPrimary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.xpVerified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.xpVerifiedIconSmall {
|
||||
width: var(--s-4);
|
||||
height: var(--s-4);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.xpVerifiedIconLarge {
|
||||
width: var(--s-5);
|
||||
height: var(--s-5);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.seasonStat {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.seasonTop {
|
||||
position: absolute;
|
||||
bottom: calc(-1 * var(--s-1));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--s-1);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-bg);
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.statDivider {
|
||||
width: 2px;
|
||||
align-self: stretch;
|
||||
background-color: var(--color-border);
|
||||
}
|
||||
|
||||
.mutualFriends {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--field-size-sm);
|
||||
}
|
||||
|
||||
.noMutualFriends {
|
||||
margin-inline: auto;
|
||||
font-style: italic;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.bio {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viewUserPage {
|
||||
margin-top: var(--s-1);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.noteView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.noteHeaderGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.noteHeader {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.noteDate {
|
||||
font-size: var(--font-2xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.noteText {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.noteViewActions {
|
||||
margin-block-start: var(--s-2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
572
app/features/user-card/components/UserCard.tsx
Normal file
572
app/features/user-card/components/UserCard.tsx
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
import clsx from "clsx";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Megaphone,
|
||||
NotebookPen,
|
||||
NotebookText,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
UserRoundCheck,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button, Dialog, DialogTrigger, Popover } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useLocation, useMatches } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { toastQueue } from "~/components/elements/Toast";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { Image, TierImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import type { XRankPlacementRegion } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MutualFriends } from "~/features/user-page/components/MutualFriends";
|
||||
import type { BrandId } from "~/modules/in-game-lists/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
brandImageUrl,
|
||||
FRIENDS_PAGE,
|
||||
LFG_PAGE,
|
||||
navIconUrl,
|
||||
stageBannerImageUrl,
|
||||
userCardEditPage,
|
||||
userCardFriendshipPage,
|
||||
userCardNotePage,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import type { UserCardFriendshipLoaderData } from "../routes/user-card.$id.friendship";
|
||||
import type {
|
||||
UserCardData,
|
||||
UserCardFriendship,
|
||||
UserCardStat,
|
||||
} from "../user-card-types";
|
||||
import { AddPrivateNoteDialog } from "./AddPrivateNoteDialog";
|
||||
import styles from "./UserCard.module.css";
|
||||
|
||||
const TENTATEK_BRAND_ID: BrandId = "B10";
|
||||
|
||||
const STAT_ORDER: Record<UserCardStat["type"], number> = {
|
||||
XP: 0,
|
||||
SEASON: 1,
|
||||
PLUS: 2,
|
||||
DIV: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Click-to-open trigger that shows a popover with the user's card. Card data is resolved from the
|
||||
* route tree by `userId` (a parent loader spreads `{ userCards }` from `UserCardRepository.userCards`);
|
||||
* 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`) 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"]);
|
||||
const lookedUpData = useUserCardData(userId);
|
||||
const data = dataProp ?? lookedUpData;
|
||||
|
||||
const user = useUser();
|
||||
const isOwnCard = user?.id === data?.id;
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
// kept at this level (outside the popover) so the modals survive the popover closing when they
|
||||
// take focus; the note view inside the card opens them
|
||||
const [isNoteDialogOpen, setIsNoteDialogOpen] = React.useState(false);
|
||||
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
|
||||
|
||||
const fetcher = useFetcher<UserCardFriendshipLoaderData>();
|
||||
const friendshipLoadedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (friendshipLoadedRef.current) return;
|
||||
if (isOwnCard) return;
|
||||
if (typeof data?.id !== "number") return;
|
||||
|
||||
friendshipLoadedRef.current = true;
|
||||
fetcher.load(userCardFriendshipPage(data.id, { withMutualFriends }));
|
||||
}, [isOpen, isOwnCard, data?.id, withMutualFriends, fetcher.load]);
|
||||
|
||||
const friendship = fetcher.data;
|
||||
|
||||
// close the popover so only the modal is shown
|
||||
const openNoteDialog = () => {
|
||||
setIsOpen(false);
|
||||
setIsNoteDialogOpen(true);
|
||||
};
|
||||
|
||||
const openDeleteConfirm = () => {
|
||||
setIsOpen(false);
|
||||
setIsDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
if (!data) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Button className={styles.trigger}>{children}</Button>
|
||||
<Popover placement="right" className={styles.popover}>
|
||||
<Dialog className={styles.dialog}>
|
||||
<CardContent
|
||||
data={data}
|
||||
friendship={friendship}
|
||||
isOwnCard={isOwnCard}
|
||||
withMutualFriends={withMutualFriends}
|
||||
onEditNote={openNoteDialog}
|
||||
onDeleteNote={openDeleteConfirm}
|
||||
/>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
{isNoteDialogOpen ? (
|
||||
<AddPrivateNoteDialog
|
||||
userId={data.id}
|
||||
username={data.username}
|
||||
note={data.privateNote}
|
||||
onClose={() => setIsNoteDialogOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<FormWithConfirm
|
||||
isOpen={isDeleteConfirmOpen}
|
||||
onOpenChange={setIsDeleteConfirmOpen}
|
||||
action={userCardNotePage(data.id)}
|
||||
fields={[["_action", "DELETE"]]}
|
||||
dialogHeading={t("q:privateNote.delete.header", {
|
||||
name: data.username,
|
||||
})}
|
||||
submitButtonText={t("common:actions.delete")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a user's `UserCardData` from any matched route loader that spread `{ userCards }`
|
||||
* (see `UserCardRepository.userCards`). Returns `undefined` when no loader on the current route
|
||||
* tree carries data for the given user.
|
||||
*/
|
||||
export function useUserCardData(
|
||||
userId: number | undefined,
|
||||
): UserCardData | undefined {
|
||||
const matches = useMatches();
|
||||
|
||||
if (typeof userId !== "number") return undefined;
|
||||
|
||||
for (const match of matches) {
|
||||
const data = match.loaderData as
|
||||
| { userCards?: Map<number, UserCardData> }
|
||||
| undefined;
|
||||
const card = data?.userCards?.get(userId);
|
||||
if (card) return card;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function CardContent({
|
||||
data,
|
||||
friendship,
|
||||
isOwnCard,
|
||||
withMutualFriends,
|
||||
onEditNote,
|
||||
onDeleteNote,
|
||||
}: {
|
||||
data: UserCardData;
|
||||
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
|
||||
friendship: UserCardFriendship | undefined;
|
||||
isOwnCard: boolean;
|
||||
withMutualFriends: boolean;
|
||||
onEditNote: () => void;
|
||||
onDeleteNote: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "user"]);
|
||||
const location = useLocation();
|
||||
|
||||
const [isNoteOpen, setIsNoteOpen] = React.useState(false);
|
||||
|
||||
const stats = data.stats.toSorted(
|
||||
(a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type],
|
||||
);
|
||||
|
||||
const editPageUrl = userCardEditPage({
|
||||
returnTo: `${location.pathname}${location.search}`,
|
||||
});
|
||||
|
||||
// an existing note shows in-place (toggled); with no note the button opens the add modal directly
|
||||
const showNoteView = isNoteOpen && data.privateNote !== null;
|
||||
const onNoteButtonPress = () => {
|
||||
if (data.privateNote === null) {
|
||||
onEditNote();
|
||||
return;
|
||||
}
|
||||
setIsNoteOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.card}
|
||||
style={customThemeStyle(data.customTheme)}
|
||||
data-custom-theme={data.customTheme ? true : undefined}
|
||||
>
|
||||
<Banner banner={data.banner} />
|
||||
{data.freeAgentPostId !== null ? (
|
||||
<LinkButton
|
||||
to={`${LFG_PAGE}#${data.freeAgentPostId}`}
|
||||
size="miniscule"
|
||||
icon={<Megaphone />}
|
||||
className={styles.freeAgentBadge}
|
||||
>
|
||||
{t("user:card.freeAgent")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
<div className={styles.iconButtons}>
|
||||
{isOwnCard ? (
|
||||
<LinkButton to={editPageUrl} size="miniscule" icon={<Pencil />}>
|
||||
{t("common:actions.edit")}
|
||||
</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
{friendship && !friendship.isFriend ? (
|
||||
<FriendRequestButton
|
||||
targetUserId={data.id}
|
||||
hasPendingFriendRequest={friendship.hasPendingFriendRequest}
|
||||
/>
|
||||
) : null}
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
shape="circle"
|
||||
icon={
|
||||
data.privateNote !== null ? <NotebookText /> : <NotebookPen />
|
||||
}
|
||||
onPress={onNoteButtonPress}
|
||||
aria-label={t("user:card.editPrivateNote")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.identity}>
|
||||
<NoteAvatar sentiment={data.privateNote?.sentiment}>
|
||||
<Avatar user={data} size="md" className={styles.avatar} />
|
||||
</NoteAvatar>
|
||||
<div className={styles.nameGroup}>
|
||||
<h2 className={styles.username}>{data.username}</h2>
|
||||
{data.customUrl ? (
|
||||
<div className={styles.subtitle}>{data.customUrl}</div>
|
||||
) : null}
|
||||
{data.friendCode ? (
|
||||
<span className={styles.friendCode}>SW-{data.friendCode}</span>
|
||||
) : (
|
||||
/** reserve space */
|
||||
<span className={styles.friendCode}>{"\u200b"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showNoteView ? (
|
||||
<NoteView
|
||||
note={data.privateNote}
|
||||
onEdit={onEditNote}
|
||||
onDelete={onDeleteNote}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{stats.length > 0 ? (
|
||||
<div className={styles.stats}>
|
||||
{stats.map((stat, i) => (
|
||||
<React.Fragment key={stat.type}>
|
||||
{i > 0 ? <span className={styles.statDivider} /> : null}
|
||||
<Stat stat={stat} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{isOwnCard || !withMutualFriends ? null : (
|
||||
<CardMutualFriends friendship={friendship} />
|
||||
)}
|
||||
{data.shortBio ? <p className={styles.bio}>{data.shortBio}</p> : null}
|
||||
<LinkButton
|
||||
to={userPage(data)}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
className={styles.viewUserPage}
|
||||
>
|
||||
{t("user:card.viewUserPage")}
|
||||
</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteView({
|
||||
note,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
note: UserCardData["privateNote"];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "user"]);
|
||||
|
||||
return (
|
||||
<div className={styles.noteView}>
|
||||
<div className={styles.noteHeaderGroup}>
|
||||
<span className={styles.noteHeader}>{t("user:card.privateNote")}</span>
|
||||
{note ? (
|
||||
<LocaleTime
|
||||
date={note.updatedAt}
|
||||
options={{ day: "numeric", month: "numeric", year: "numeric" }}
|
||||
className={styles.noteDate}
|
||||
inline
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{note?.text ? <p className={styles.noteText}>{note.text}</p> : null}
|
||||
<div className={styles.noteViewActions}>
|
||||
<SendouButton
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
icon={<Pencil />}
|
||||
onPress={onEdit}
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="miniscule"
|
||||
icon={<Trash2 />}
|
||||
onPress={onDelete}
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send friend request action on the card. Submits to the `/friends` route action and shows a
|
||||
* checkmark once a request is pending (server-known or just sent). Cancelling a pending request
|
||||
* is done on the `/friends` page.
|
||||
*/
|
||||
function FriendRequestButton({
|
||||
targetUserId,
|
||||
hasPendingFriendRequest,
|
||||
}: {
|
||||
targetUserId: number;
|
||||
hasPendingFriendRequest: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const fetcher = useFetcher();
|
||||
const previousStateRef = React.useRef(fetcher.state);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
previousStateRef.current !== "idle" &&
|
||||
fetcher.state === "idle" &&
|
||||
fetcher.data === null
|
||||
) {
|
||||
toastQueue.add(
|
||||
{
|
||||
message: t("user:card.friendRequestSent"),
|
||||
variant: "success",
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
}
|
||||
previousStateRef.current = fetcher.state;
|
||||
}, [fetcher.state, fetcher.data, t]);
|
||||
|
||||
const requestPending =
|
||||
hasPendingFriendRequest ||
|
||||
fetcher.state !== "idle" ||
|
||||
fetcher.data === null;
|
||||
|
||||
if (requestPending) {
|
||||
return (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
shape="circle"
|
||||
icon={<UserRoundCheck />}
|
||||
isDisabled
|
||||
aria-label={t("user:card.friendRequestPending")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
shape="circle"
|
||||
icon={<UserPlus />}
|
||||
aria-label={t("user:card.sendFriendRequest")}
|
||||
onPress={() =>
|
||||
fetcher.submit(
|
||||
{ _action: "SEND_REQUEST", userId: targetUserId },
|
||||
{ method: "post", action: FRIENDS_PAGE },
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutual friends row with reserved height so the card does not shift when the lazy friendship fetch
|
||||
* resolves: empty while loading, "No mutual friends" when there are none, the avatar stack otherwise.
|
||||
*/
|
||||
function CardMutualFriends({
|
||||
friendship,
|
||||
}: {
|
||||
friendship: UserCardFriendship | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<div className={styles.mutualFriends}>
|
||||
{friendship === undefined ? null : friendship.mutualFriends.length ===
|
||||
0 ? (
|
||||
<span className={styles.noMutualFriends}>
|
||||
{t("user:card.noMutualFriends")}
|
||||
</span>
|
||||
) : (
|
||||
<MutualFriends
|
||||
mutualFriends={friendship.mutualFriends}
|
||||
withoutPopover
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ banner }: { banner: UserCardData["banner"] }) {
|
||||
const style = (() => {
|
||||
switch (banner.type) {
|
||||
case "STAGE":
|
||||
return {
|
||||
backgroundImage: `url(${stageBannerImageUrl(banner.stageId)})`,
|
||||
};
|
||||
case "URL":
|
||||
return { backgroundImage: `url(${banner.url})` };
|
||||
case "COLOR":
|
||||
return { backgroundColor: banner.hexCode };
|
||||
default:
|
||||
assertUnreachable(banner);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={style}
|
||||
data-testid="user-card-banner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ stat }: { stat: UserCardData["stats"][number] }) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
switch (stat.type) {
|
||||
case "XP": {
|
||||
const unverified = stat.values.find((value) => !value.isVerified);
|
||||
const verified = stat.values.find((value) => value.isVerified);
|
||||
const primary = unverified ?? verified;
|
||||
const secondary = unverified ? verified : undefined;
|
||||
|
||||
return (
|
||||
<span className={clsx(styles.stat, styles.xpStat)}>
|
||||
{primary ? (
|
||||
<span className={styles.xpPrimary}>
|
||||
{primary.isVerified ? (
|
||||
<BadgeCheck className={styles.xpVerifiedIconLarge} />
|
||||
) : null}
|
||||
<DivImage region={primary.region} />
|
||||
{primary.points}
|
||||
{t("user:card.xp")}
|
||||
</span>
|
||||
) : null}
|
||||
{secondary ? (
|
||||
<span className={styles.xpVerified}>
|
||||
<BadgeCheck className={styles.xpVerifiedIconSmall} />
|
||||
<DivImage region={secondary.region} />
|
||||
{secondary.points}
|
||||
{t("user:card.xp")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "DIV":
|
||||
return <span className={styles.stat}>Div {stat.value}</span>;
|
||||
case "PLUS":
|
||||
return (
|
||||
<span className={clsx(styles.stat, styles.plusStat)}>
|
||||
<Image path={navIconUrl("plus")} alt="+" size={24} />
|
||||
{stat.value}
|
||||
</span>
|
||||
);
|
||||
case "SEASON":
|
||||
return (
|
||||
<span className={styles.seasonStat}>
|
||||
<TierImage tier={stat.value} width={32} />
|
||||
{typeof stat.top === "number" ? (
|
||||
<span className={styles.seasonTop}>
|
||||
<Placement
|
||||
placement={stat.top}
|
||||
size={14}
|
||||
showAsSuperscript={false}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
assertUnreachable(stat);
|
||||
}
|
||||
}
|
||||
|
||||
function DivImage({ region }: { region: XRankPlacementRegion }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
if (region !== "WEST") return null;
|
||||
|
||||
return (
|
||||
<Image
|
||||
path={brandImageUrl(TENTATEK_BRAND_ID)}
|
||||
alt={t("common:divisions.WEST")}
|
||||
width={18}
|
||||
height={18}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function customThemeStyle(
|
||||
customTheme: UserCardData["customTheme"],
|
||||
): React.CSSProperties {
|
||||
if (!customTheme) return {};
|
||||
|
||||
return R.pickBy(
|
||||
customTheme,
|
||||
(value, key) =>
|
||||
value !== null && !key.includes("--_size") && !key.includes("--_border"),
|
||||
) as React.CSSProperties;
|
||||
}
|
||||
28
app/features/user-card/loaders/user-card.edit.server.ts
Normal file
28
app/features/user-card/loaders/user-card.edit.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as UserCardRepository from "../UserCardRepository.server";
|
||||
|
||||
export const loader = async () => {
|
||||
const user = requireUser();
|
||||
|
||||
const [{ userCards }, extras, hasLinkedPlayer] = await Promise.all([
|
||||
UserCardRepository.userCards({
|
||||
userIds: [user.id],
|
||||
includeHiddenStats: true,
|
||||
}),
|
||||
UserCardRepository.cardEditExtras(user.id),
|
||||
XRankPlacementRepository.isPlayerLinkedByUserId(user.id),
|
||||
]);
|
||||
|
||||
const card = userCards.get(user.id);
|
||||
invariant(card, "card data not found for own user");
|
||||
|
||||
return {
|
||||
card,
|
||||
extras,
|
||||
isSupporter: Boolean(user.roles?.includes("SUPPORTER")),
|
||||
presentStats: card.stats.map((stat) => stat.type),
|
||||
hasLinkedPlayer,
|
||||
};
|
||||
};
|
||||
56
app/features/user-card/routes/user-card.$id.friendship.ts
Normal file
56
app/features/user-card/routes/user-card.$id.friendship.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as FriendRepository from "~/features/friends/FriendRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import type { UserCardFriendship } from "../user-card-types";
|
||||
|
||||
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. 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);
|
||||
|
||||
if (!viewer || Number.isNaN(targetUserId)) {
|
||||
return {
|
||||
isFriend: false,
|
||||
hasPendingFriendRequest: false,
|
||||
mutualFriends: [],
|
||||
};
|
||||
}
|
||||
|
||||
const withMutualFriends =
|
||||
new URL(request.url).searchParams.get("mutuals") === "true";
|
||||
|
||||
const [friendship, pendingRequest, mutualFriends] = await Promise.all([
|
||||
FriendRepository.findFriendship({
|
||||
userOneId: viewer.id,
|
||||
userTwoId: targetUserId,
|
||||
}),
|
||||
FriendRepository.findFriendRequestBetween({
|
||||
senderId: viewer.id,
|
||||
receiverId: targetUserId,
|
||||
}),
|
||||
withMutualFriends
|
||||
? FriendRepository.findMutualFriends({
|
||||
loggedInUserId: viewer.id,
|
||||
targetUserId,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
isFriend: Boolean(friendship),
|
||||
hasPendingFriendRequest: Boolean(pendingRequest),
|
||||
mutualFriends,
|
||||
};
|
||||
};
|
||||
39
app/features/user-card/routes/user-card.$id.note.ts
Normal file
39
app/features/user-card/routes/user-card.$id.note.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
|
||||
import { parseParams, parseRequestPayload } from "~/utils/remix.server";
|
||||
import {
|
||||
userCardNoteParamsSchema,
|
||||
userCardNoteSchema,
|
||||
} from "../user-card-schemas";
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
requireUser();
|
||||
|
||||
const targetId = parseParams({
|
||||
params,
|
||||
schema: userCardNoteParamsSchema,
|
||||
}).id;
|
||||
const data = await parseRequestPayload({
|
||||
request,
|
||||
schema: userCardNoteSchema,
|
||||
});
|
||||
|
||||
const isEmptySave =
|
||||
data._action === "SAVE" &&
|
||||
data.comment === null &&
|
||||
data.sentiment === "NEUTRAL";
|
||||
|
||||
if (data._action === "DELETE" || isEmptySave) {
|
||||
await PrivateUserNoteRepository.deleteOwnNoteById(targetId);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await PrivateUserNoteRepository.upsertOwnNote({
|
||||
targetId,
|
||||
sentiment: data.sentiment,
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
31
app/features/user-card/routes/user-card.edit.module.css
Normal file
31
app/features/user-card/routes/user-card.edit.module.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.swatchesLegend {
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: var(--s-1-5);
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-selector);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border-high);
|
||||
}
|
||||
}
|
||||
|
||||
.swatchSelected {
|
||||
border-color: var(--color-text);
|
||||
}
|
||||
|
||||
.supporterOnly {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
141
app/features/user-card/routes/user-card.edit.tsx
Normal file
141
app/features/user-card/routes/user-card.edit.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
type MetaFunction,
|
||||
useLoaderData,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import type { XRankPlacementRegion } from "~/db/tables";
|
||||
import { HowToLinkPopover } from "~/features/top-search/components/HowToLinkPopover";
|
||||
import { type CustomFieldRenderProps, FormField } from "~/form/FormField";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { userCardEditPage } from "~/utils/urls";
|
||||
import { PRESET_COLORS } from "../../tier-list-maker/tier-list-maker-constants";
|
||||
import { action } from "../actions/user-card.edit.server";
|
||||
import { loader } from "../loaders/user-card.edit.server";
|
||||
import { updateUserCardSchema } from "../user-card-schemas";
|
||||
import styles from "./user-card.edit.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user"],
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Edit user card",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export default function UserCardEditPage() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const returnTo = searchParams.get("returnTo");
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<div className="stack md">
|
||||
<div>
|
||||
<h2 className="text-lg">{t("user:card.edit.title")}</h2>
|
||||
{!data.hasLinkedPlayer ? <HowToLinkPopover /> : null}
|
||||
</div>
|
||||
<SendouForm
|
||||
schema={updateUserCardSchema}
|
||||
action={returnTo ? userCardEditPage({ returnTo }) : undefined}
|
||||
defaultValues={defaultValues(data)}
|
||||
>
|
||||
<CardEditFields />
|
||||
</SendouForm>
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultValues(data: Awaited<ReturnType<typeof loader>>) {
|
||||
const { card, extras } = data;
|
||||
const banner = card.banner;
|
||||
const peakXp = extras.unverifiedPeakXP;
|
||||
|
||||
return {
|
||||
shortBio: card.shortBio ?? "",
|
||||
bannerType: banner.type,
|
||||
bannerColor: banner.type === "COLOR" ? banner.hexCode : PRESET_COLORS[0],
|
||||
bannerStageId: banner.type === "STAGE" ? banner.stageId : 1,
|
||||
bannerImage: existingImage(extras.bannerImgId, extras.bannerImageUrl),
|
||||
unverifiedXpPoints: peakXp?.overall,
|
||||
unverifiedXpDivision: (typeof peakXp?.takoroka === "number"
|
||||
? "JPN"
|
||||
: "WEST") as XRankPlacementRegion,
|
||||
hideXp: extras.hiddenCardStats.includes("XP"),
|
||||
hideDiv: extras.hiddenCardStats.includes("DIV"),
|
||||
};
|
||||
}
|
||||
|
||||
function CardEditFields() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const { isSupporter, presentStats } = useLoaderData<typeof loader>();
|
||||
const { values } = useFormFieldContext();
|
||||
|
||||
const bannerType = values.bannerType as "COLOR" | "STAGE" | "URL";
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<FormField name="shortBio" />
|
||||
<FormField name="bannerType" />
|
||||
{bannerType === "COLOR" ? <BannerColorField /> : null}
|
||||
{bannerType === "STAGE" ? <FormField name="bannerStageId" /> : null}
|
||||
{bannerType === "URL" ? (
|
||||
isSupporter ? (
|
||||
<FormField name="bannerImage" />
|
||||
) : (
|
||||
<p className={styles.supporterOnly}>
|
||||
{t("user:card.edit.supporterOnlyBanner")}
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
<FormField name="unverifiedXpPoints" />
|
||||
<FormField name="unverifiedXpDivision" />
|
||||
{presentStats.includes("XP") ? <FormField name="hideXp" /> : null}
|
||||
{presentStats.includes("DIV") ? <FormField name="hideDiv" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BannerColorField() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<FormField name="bannerColor">
|
||||
{({ value, onChange }: CustomFieldRenderProps) => (
|
||||
<fieldset>
|
||||
<legend className={styles.swatchesLegend}>
|
||||
{t("user:card.edit.bannerColor")}
|
||||
</legend>
|
||||
<div className={styles.swatches}>
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={clsx(styles.swatch, {
|
||||
[styles.swatchSelected]: value === color,
|
||||
})}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => onChange(color)}
|
||||
aria-label={color}
|
||||
aria-pressed={value === color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
17
app/features/user-card/user-card-constants.ts
Normal file
17
app/features/user-card/user-card-constants.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export const USER_CARD = {
|
||||
SHORT_BIO_MAX_LENGTH: 64,
|
||||
/**
|
||||
* Highest self-reported peak XP accepted for a user with no linked, verified
|
||||
* Splatoon player. The Takoroka (JPN) division has a higher ceiling than
|
||||
* Tentatek (WEST).
|
||||
*/
|
||||
MAX_UNVERIFIED_XP_BY_DIVISION: {
|
||||
WEST: 2200,
|
||||
JPN: 2700,
|
||||
},
|
||||
/**
|
||||
* Extra peak XP a user with a linked, verified Splatoon player may self-report
|
||||
* on top of their division's cap.
|
||||
*/
|
||||
MAX_UNVERIFIED_XP_LINKED_PLAYER_BONUS: 200,
|
||||
} as const;
|
||||
70
app/features/user-card/user-card-schemas.ts
Normal file
70
app/features/user-card/user-card-schemas.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { z } from "zod";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import {
|
||||
customField,
|
||||
image,
|
||||
numberFieldOptional,
|
||||
select,
|
||||
stageSelect,
|
||||
textAreaOptional,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { _action, falsyToNull, id } from "~/utils/zod";
|
||||
import { PRESET_COLORS } from "../tier-list-maker/tier-list-maker-constants";
|
||||
import { USER_CARD } from "./user-card-constants";
|
||||
|
||||
export const updateUserCardSchema = z.object({
|
||||
shortBio: textAreaOptional({
|
||||
label: "labels.shortBio",
|
||||
maxLength: USER_CARD.SHORT_BIO_MAX_LENGTH,
|
||||
}),
|
||||
bannerType: select({
|
||||
label: "labels.banner",
|
||||
items: [
|
||||
{ label: "options.bannerType.COLOR", value: "COLOR" },
|
||||
{ label: "options.bannerType.STAGE", value: "STAGE" },
|
||||
{ label: "options.bannerType.URL", value: "URL" },
|
||||
],
|
||||
}),
|
||||
bannerColor: customField(
|
||||
{ initialValue: PRESET_COLORS[0] },
|
||||
z.string().regex(/^#[0-9a-f]{6}$/i),
|
||||
),
|
||||
bannerStageId: stageSelect({ label: "labels.bannerStage" }),
|
||||
bannerImage: image({
|
||||
label: "labels.bannerImage",
|
||||
dimensions: "thick-banner",
|
||||
autoValidate: true,
|
||||
}),
|
||||
unverifiedXpPoints: numberFieldOptional({
|
||||
label: "labels.unverifiedXp",
|
||||
bottomText: "bottomTexts.unverifiedXp",
|
||||
}),
|
||||
unverifiedXpDivision: select({
|
||||
label: "labels.division",
|
||||
items: [
|
||||
{ label: "options.xpDivision.WEST", value: "WEST" },
|
||||
{ label: "options.xpDivision.JPN", value: "JPN" },
|
||||
],
|
||||
}),
|
||||
hideXp: toggle({ label: "labels.hideXp" }),
|
||||
hideDiv: toggle({ label: "labels.hideDiv" }),
|
||||
});
|
||||
|
||||
export const userCardNoteSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("SAVE"),
|
||||
comment: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE"),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const userCardNoteParamsSchema = z.object({
|
||||
id,
|
||||
});
|
||||
70
app/features/user-card/user-card-types.ts
Normal file
70
app/features/user-card/user-card-types.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import type { CustomTheme, Tables, XRankPlacementRegion } from "~/db/tables";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import type { TieredSkill } from "../mmr/tiered.server";
|
||||
|
||||
export interface UserCardData extends CommonUser {
|
||||
banner: UserCarBannerData;
|
||||
shortBio: string | null;
|
||||
customTheme: CustomTheme | null;
|
||||
friendCode: string | null;
|
||||
/** Id of the user's free agent LFG post, or `null` if they have none. */
|
||||
freeAgentPostId: number | null;
|
||||
/** The viewer's private note about this user, or `null` when they have none. */
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"text" | "sentiment" | "updatedAt"
|
||||
> | null;
|
||||
stats: Array<UserCardStat>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewer-relative card fields lazy-loaded when the card opens (see the
|
||||
* `/user-card/:id/friendship` resource route), kept out of the batched `UserCardData`
|
||||
* query because they are only needed for the one card a viewer actually opens.
|
||||
*/
|
||||
export interface UserCardFriendship {
|
||||
isFriend: boolean;
|
||||
/** Whether a friend request between the viewer and this user is currently pending. */
|
||||
hasPendingFriendRequest: boolean;
|
||||
mutualFriends: Array<CommonUser>;
|
||||
}
|
||||
|
||||
type UserCarBannerData =
|
||||
| {
|
||||
type: "URL";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: "COLOR";
|
||||
hexCode: string;
|
||||
}
|
||||
| {
|
||||
type: "STAGE";
|
||||
stageId: StageId;
|
||||
};
|
||||
|
||||
export type UserCardStat =
|
||||
| {
|
||||
type: "XP";
|
||||
values: Array<UserCardStatXPValue>;
|
||||
}
|
||||
| {
|
||||
type: "DIV";
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
type: "PLUS";
|
||||
value: number;
|
||||
}
|
||||
| {
|
||||
type: "SEASON";
|
||||
value: TieredSkill["tier"];
|
||||
top: number | null;
|
||||
};
|
||||
|
||||
export interface UserCardStatXPValue {
|
||||
isVerified: boolean;
|
||||
region: XRankPlacementRegion;
|
||||
points: number;
|
||||
}
|
||||
19
app/features/user-card/user-card-utils.ts
Normal file
19
app/features/user-card/user-card-utils.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { XRankPlacementRegion } from "~/db/tables";
|
||||
import { USER_CARD } from "./user-card-constants";
|
||||
|
||||
/**
|
||||
* Highest self-reported peak XP accepted for the given division. A user with a
|
||||
* linked, verified Splatoon player is allowed a higher cap than one without.
|
||||
*/
|
||||
export function maxUnverifiedXp({
|
||||
division,
|
||||
hasLinkedPlayer,
|
||||
}: {
|
||||
division: XRankPlacementRegion;
|
||||
hasLinkedPlayer: boolean;
|
||||
}) {
|
||||
const base = USER_CARD.MAX_UNVERIFIED_XP_BY_DIVISION[division];
|
||||
return hasLinkedPlayer
|
||||
? base + USER_CARD.MAX_UNVERIFIED_XP_LINKED_PLAYER_BONUS
|
||||
: base;
|
||||
}
|
||||
|
|
@ -1140,6 +1140,23 @@ export function updateOwnProfile(args: UpdateProfileArgs) {
|
|||
});
|
||||
}
|
||||
|
||||
/** Bulk-sets each user's latest LUTI division. Used by the `ComputeLutiDivs` routine. */
|
||||
export function updateManyDivs(
|
||||
updates: Array<{ userId: number; div: string }>,
|
||||
) {
|
||||
if (updates.length === 0) return;
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
for (const { userId, div } of updates) {
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set({ div })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOwnCustomTheme(css: CustomTheme | null) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-bold);
|
||||
|
||||
span {
|
||||
color: var(--color-text-high);
|
||||
|
|
|
|||
|
|
@ -3,23 +3,29 @@ import { Link } from "react-router";
|
|||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import styles from "./MutualFriends.module.css";
|
||||
|
||||
const MAX_VISIBLE_AVATARS = 5;
|
||||
|
||||
export function MutualFriends({
|
||||
mutualFriends,
|
||||
withoutPopover = false,
|
||||
}: {
|
||||
mutualFriends: UserPageLoaderData["mutualFriends"];
|
||||
mutualFriends: Array<CommonUser>;
|
||||
/** When true renders a static avatar stack without the interactive popover, e.g. on the user card. */
|
||||
withoutPopover?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
if (mutualFriends.length === 0) return null;
|
||||
|
||||
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
|
||||
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
|
||||
if (withoutPopover) {
|
||||
return (
|
||||
<div className={styles.trigger}>
|
||||
<AvatarStack mutualFriends={mutualFriends} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -27,24 +33,7 @@ export function MutualFriends({
|
|||
trigger={
|
||||
<SendouButton variant="minimal" size="small">
|
||||
<div className={styles.trigger}>
|
||||
<div className={styles.avatarStack}>
|
||||
{visibleFriends.map((friend) => (
|
||||
<Avatar
|
||||
key={friend.id}
|
||||
user={friend}
|
||||
size="xxs"
|
||||
className={styles.stackedAvatar}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{overflowCount > 0 ? (
|
||||
<span className={styles.overflow}>+{overflowCount}</span>
|
||||
) : null}
|
||||
<span>
|
||||
{t("user:mutualFriends.count", {
|
||||
count: mutualFriends.length,
|
||||
})}
|
||||
</span>
|
||||
<AvatarStack mutualFriends={mutualFriends} />
|
||||
</div>
|
||||
</SendouButton>
|
||||
}
|
||||
|
|
@ -65,3 +54,33 @@ export function MutualFriends({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarStack({ mutualFriends }: { mutualFriends: Array<CommonUser> }) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS);
|
||||
const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.avatarStack}>
|
||||
{visibleFriends.map((friend) => (
|
||||
<Avatar
|
||||
key={friend.id}
|
||||
user={friend}
|
||||
size="xxs"
|
||||
className={styles.stackedAvatar}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{overflowCount > 0 ? (
|
||||
<span className={styles.overflow}>+{overflowCount}</span>
|
||||
) : null}
|
||||
<span>
|
||||
{t("user:mutualFriends.count", {
|
||||
count: mutualFriends.length,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,18 @@ export default [
|
|||
|
||||
route("/friends", "features/friends/routes/friends.tsx"),
|
||||
|
||||
route("/user-card/edit", "features/user-card/routes/user-card.edit.tsx"),
|
||||
|
||||
route(
|
||||
"/user-card/:id/friendship",
|
||||
"features/user-card/routes/user-card.$id.friendship.ts",
|
||||
),
|
||||
|
||||
route(
|
||||
"/user-card/:id/note",
|
||||
"features/user-card/routes/user-card.$id.note.ts",
|
||||
),
|
||||
|
||||
route("/events", "features/calendar/routes/events.tsx"),
|
||||
|
||||
route("/suspended", "features/ban/routes/suspended.tsx"),
|
||||
|
|
|
|||
56
app/routines/computeLutiDivs.ts
Normal file
56
app/routines/computeLutiDivs.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { parseLutiDivFromName } from "../features/scrims/scrims-utils";
|
||||
import * as TournamentRepository from "../features/tournament/TournamentRepository.server";
|
||||
import { LEAGUES } from "../features/tournament/tournament-constants";
|
||||
import * as UserRepository from "../features/user-page/UserRepository.server";
|
||||
import { logger } from "../utils/logger";
|
||||
import { Routine } from "./routine.server";
|
||||
|
||||
/**
|
||||
* Recomputes `User.div` (the user's division in the latest finished LUTI). Looks at the most recent
|
||||
* LUTI season whose division tournaments are all finalized and sets the division for every eligible
|
||||
* participant (on a team that did not drop out and played at least one match). Users not in that
|
||||
* season keep their previous division. Idempotent.
|
||||
*/
|
||||
export const ComputeLutiDivsRoutine = new Routine({
|
||||
name: "ComputeLutiDivs",
|
||||
func: async () => {
|
||||
const children = await latestFinishedLutiDivisions();
|
||||
if (!children) return;
|
||||
|
||||
const updates: Array<{ userId: number; div: string }> = [];
|
||||
for (const child of children) {
|
||||
const div = parseLutiDivFromName(child.name);
|
||||
if (!div) {
|
||||
logger.warn(
|
||||
`ComputeLutiDivs: could not parse division from tournament name "${child.name}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const userIds =
|
||||
await TournamentRepository.findLeagueDivParticipantUserIds(
|
||||
child.tournamentId,
|
||||
);
|
||||
for (const { userId } of userIds) {
|
||||
updates.push({ userId, div });
|
||||
}
|
||||
}
|
||||
|
||||
await UserRepository.updateManyDivs(updates);
|
||||
logger.info(`ComputeLutiDivs: updated div for ${updates.length} users`);
|
||||
},
|
||||
});
|
||||
|
||||
async function latestFinishedLutiDivisions() {
|
||||
for (const league of [...(LEAGUES.LUTI ?? [])].reverse()) {
|
||||
const children = await TournamentRepository.findChildTournamentsForDivCalc(
|
||||
league.tournamentId,
|
||||
);
|
||||
if (children.length === 0) continue;
|
||||
if (children.every((child) => child.isFinalized === 1)) {
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { CloseExpiredCommissionsRoutine } from "./closeExpiredCommissions";
|
||||
import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
|
||||
import { ComputeLutiDivsRoutine } from "./computeLutiDivs";
|
||||
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
|
||||
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
|
||||
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
|
||||
|
|
@ -43,6 +44,7 @@ export const daily = [
|
|||
DeleteOldTournamentAuditLogsRoutine,
|
||||
CloseExpiredCommissionsRoutine,
|
||||
DeleteOrphanArtTagsRoutine,
|
||||
ComputeLutiDivsRoutine,
|
||||
OptimizeDatabaseRoutine,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ cannot use currentColor inside data URLs
|
|||
|
||||
Any changes here NEED to be reflected in oklch-gamut.ts as well
|
||||
*/
|
||||
html.dark {
|
||||
html.dark,
|
||||
html.dark [data-custom-theme] {
|
||||
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
|
||||
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
|
||||
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
|
||||
|
|
@ -129,7 +130,8 @@ html.dark {
|
|||
--field-icon-time: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(145, 145, 145)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
html.light {
|
||||
html.light,
|
||||
html.light [data-custom-theme] {
|
||||
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
|
||||
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
|
||||
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
|
||||
|
|
@ -182,7 +184,9 @@ html.light {
|
|||
/*
|
||||
These are the vars you mainly want to use
|
||||
*/
|
||||
html {
|
||||
html,
|
||||
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
|
||||
[data-custom-theme] {
|
||||
--color-text: var(--color-base-0);
|
||||
--color-text-high: var(--color-base-3);
|
||||
--color-text-inverse: var(--color-base-7);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@ export function withUserId<T>(id: number, fn: () => T): T {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` inside a user AsyncLocalStorage store with no acting user, mirroring an
|
||||
* anonymous visitor's request. Repository functions resolving the actor via
|
||||
* `actorIdOrNull()` then see `null`, as they would inside a real request context.
|
||||
*/
|
||||
export function withNoUser<T>(fn: () => T): T {
|
||||
return userAsyncLocalStorage.run({ user: undefined }, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an action function to provide a strongly-typed, reusable handler for executing actions
|
||||
* in unit tests as if it was a normal function. The returned function allows you to pass
|
||||
|
|
|
|||
|
|
@ -55,21 +55,24 @@ const userChatNameHueRaw = sql<
|
|||
|
||||
export const userChatNameHue = userChatNameHueRaw.as("chatNameHue");
|
||||
|
||||
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject({
|
||||
/**
|
||||
* The {@link CommonUser} fields as a plain record of Kysely expressions, for spreading into a
|
||||
* hand-built `jsonBuildObject` alongside extra fields. Prefer {@link commonUserJsonObject} when the
|
||||
* common fields are the whole object.
|
||||
*/
|
||||
export function commonUserObjectFields(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return {
|
||||
id: eb.ref("User.id"),
|
||||
username: eb.ref("User.username"),
|
||||
discordId: eb.ref("User.discordId"),
|
||||
discordAvatar: eb.ref("User.discordAvatar"),
|
||||
customUrl: eb.ref("User.customUrl"),
|
||||
customAvatarUrl: concatUserSubmittedImagePrefix(
|
||||
eb
|
||||
.selectFrom("UserSubmittedImage")
|
||||
.select("UserSubmittedImage.url")
|
||||
.whereRef("UserSubmittedImage.id", "=", "User.customAvatarImgId")
|
||||
.$asScalar(),
|
||||
).$castTo<string | null>(),
|
||||
});
|
||||
customAvatarUrl: customAvatarUrl(eb),
|
||||
};
|
||||
}
|
||||
|
||||
export function commonUserJsonObject(eb: ExpressionBuilder<Tables, "User">) {
|
||||
return jsonBuildObject(commonUserObjectFields(eb));
|
||||
}
|
||||
|
||||
const USER_SUBMITTED_IMAGE_ROOT =
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export const LFG_PAGE = "/lfg";
|
|||
export const EVENTS_PAGE = "/events";
|
||||
export const FRIENDS_PAGE = "/friends";
|
||||
export const SETTINGS_PAGE = "/settings";
|
||||
const USER_CARD_EDIT_PAGE = "/user-card/edit";
|
||||
export const LUTI_PAGE = "/luti";
|
||||
export const PLUS_VOTING_PAGE = "/plus/voting";
|
||||
|
||||
|
|
@ -141,6 +142,16 @@ 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,
|
||||
args?: { withMutualFriends?: boolean },
|
||||
) =>
|
||||
`/user-card/${userId}/friendship${
|
||||
args?.withMutualFriends ? "?mutuals=true" : ""
|
||||
}`;
|
||||
|
||||
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
|
||||
|
||||
interface UserLinkArgs {
|
||||
discordId: Tables["User"]["discordId"];
|
||||
customUrl?: Tables["User"]["customUrl"];
|
||||
|
|
@ -181,6 +192,10 @@ export const userBuildsPage = (user: UserLinkArgs) =>
|
|||
export const userResultsPage = (user: UserLinkArgs, showAll?: boolean) =>
|
||||
`${userPage(user)}/results${showAll ? "?all=true" : ""}`;
|
||||
export const userVodsPage = (user: UserLinkArgs) => `${userPage(user)}/vods`;
|
||||
export const userCardEditPage = (args?: { returnTo?: string }) =>
|
||||
`${USER_CARD_EDIT_PAGE}${
|
||||
args?.returnTo ? `?returnTo=${encodeURIComponent(args.returnTo)}` : ""
|
||||
}`;
|
||||
export const newVodPage = (vodToEditId?: number) =>
|
||||
`${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`;
|
||||
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
39
e2e/user-card.spec.ts
Normal file
39
e2e/user-card.spec.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
import {
|
||||
expect,
|
||||
impersonate,
|
||||
navigate,
|
||||
seed,
|
||||
submit,
|
||||
test,
|
||||
} from "./helpers/playwright";
|
||||
|
||||
test.describe("User card", () => {
|
||||
test("edits banner and bio from the looking page", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
|
||||
|
||||
const ownGroup = page.getByTestId("sendouq-group-card").first();
|
||||
await ownGroup.getByRole("button", { name: "Sendou" }).click();
|
||||
|
||||
await page.getByRole("link", { name: "Edit" }).click();
|
||||
await expect(page).toHaveURL(/\/user-card\/edit/);
|
||||
|
||||
const newBio = "New bio from e2e test";
|
||||
await page.getByLabel("Short bio").fill(newBio);
|
||||
await page.getByLabel("Banner", { exact: true }).selectOption("COLOR");
|
||||
await page.getByRole("button", { name: "#4169e1" }).click();
|
||||
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(SENDOUQ_LOOKING_PAGE);
|
||||
|
||||
await ownGroup.getByRole("button", { name: "Sendou" }).click();
|
||||
await expect(page.getByText(newBio)).toBeVisible();
|
||||
await expect(page.getByTestId("user-card-banner")).toHaveCSS(
|
||||
"background-color",
|
||||
"rgb(65, 105, 225)",
|
||||
);
|
||||
});
|
||||
});
|
||||
1
knip.ts
1
knip.ts
|
|
@ -4,7 +4,6 @@ const config = {
|
|||
type: true,
|
||||
},
|
||||
tags: ["-lintignore"],
|
||||
ignore: ["scripts/dicts/**"],
|
||||
entry: [
|
||||
"app/features/*/routes/**/*.{ts,tsx}",
|
||||
"migrations/**/*.js",
|
||||
|
|
|
|||
|
|
@ -296,6 +296,14 @@
|
|||
"xsearch.unlink.title": "",
|
||||
"xsearch.unlink.action.long": "",
|
||||
"xsearch.unlink.action.short": "",
|
||||
"xsearch.link.trigger": "",
|
||||
"xsearch.link.about.title": "",
|
||||
"xsearch.link.about.endPower": "",
|
||||
"xsearch.link.about.notPeak": "",
|
||||
"xsearch.link.about.wait": "",
|
||||
"xsearch.link.example": "",
|
||||
"xsearch.link.exampleRequest": "",
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privat",
|
||||
"or": "Eller",
|
||||
"yes": "Ja",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,19 @@
|
|||
"labels.bio": "Biografi",
|
||||
"labels.logo": "",
|
||||
"labels.banner": "",
|
||||
"labels.bannerStage": "",
|
||||
"labels.bannerImage": "",
|
||||
"labels.shortBio": "",
|
||||
"labels.unverifiedXp": "",
|
||||
"labels.hideXp": "",
|
||||
"labels.hideDiv": "",
|
||||
"options.bannerType.COLOR": "",
|
||||
"options.bannerType.STAGE": "",
|
||||
"options.bannerType.URL": "",
|
||||
"options.xpDivision.WEST": "",
|
||||
"options.xpDivision.JPN": "",
|
||||
"bottomTexts.unverifiedXp": "",
|
||||
"errors.unverifiedXpTooHigh": "",
|
||||
"labels.link": "",
|
||||
"labels.tag": "",
|
||||
"labels.teamBsky": "",
|
||||
|
|
|
|||
|
|
@ -203,5 +203,17 @@
|
|||
"commissions.closed": "",
|
||||
"mutualFriends": "",
|
||||
"mutualFriends.count_one": "",
|
||||
"mutualFriends.count_other": ""
|
||||
"mutualFriends.count_other": "",
|
||||
"card.viewUserPage": "",
|
||||
"card.sendFriendRequest": "",
|
||||
"card.friendRequestPending": "",
|
||||
"card.friendRequestSent": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
"card.noMutualFriends": "",
|
||||
"card.edit.title": "",
|
||||
"card.edit.bannerColor": "",
|
||||
"card.edit.supporterOnlyBanner": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -296,6 +296,14 @@
|
|||
"xsearch.unlink.title": "",
|
||||
"xsearch.unlink.action.long": "",
|
||||
"xsearch.unlink.action.short": "",
|
||||
"xsearch.link.trigger": "",
|
||||
"xsearch.link.about.title": "",
|
||||
"xsearch.link.about.endPower": "",
|
||||
"xsearch.link.about.notPeak": "",
|
||||
"xsearch.link.about.wait": "",
|
||||
"xsearch.link.example": "",
|
||||
"xsearch.link.exampleRequest": "",
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "",
|
||||
"or": "",
|
||||
"yes": "",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user