diff --git a/app/components/NoteAvatar.module.css b/app/components/NoteAvatar.module.css index a4767dcb2..27822064a 100644 --- a/app/components/NoteAvatar.module.css +++ b/app/components/NoteAvatar.module.css @@ -7,8 +7,9 @@ .badge { position: absolute; - bottom: -2px; - left: -2px; + bottom: 15%; + left: 15%; + transform: translate(-50%, 50%); display: grid; place-items: center; border-radius: var(--radius-full); diff --git a/app/components/NoteAvatar.tsx b/app/components/NoteAvatar.tsx index bd82c3b58..e572c2938 100644 --- a/app/components/NoteAvatar.tsx +++ b/app/components/NoteAvatar.tsx @@ -29,7 +29,6 @@ const SIZE_CLASS = { * children without a badge when `sentiment` is `null`/`undefined`. `size` scales the badge to match * the wrapped avatar (`sm` for small avatars, `md` for large ones). */ -// xxx: same position for the icon no matter if big or small export function NoteAvatar({ sentiment, size = "md", diff --git a/app/features/components-showcase/routes/components.tsx b/app/features/components-showcase/routes/components.tsx index f22f2ac62..615032add 100644 --- a/app/features/components-showcase/routes/components.tsx +++ b/app/features/components-showcase/routes/components.tsx @@ -1588,6 +1588,7 @@ const USER_CARD_DATA = { privateNote: { text: "Played with them, very friendly", sentiment: "POSITIVE", + updatedAt: 1704067200, }, stats: [ { @@ -1608,7 +1609,6 @@ const USER_CARD_DATA = { { type: "DIV", value: "1" }, { type: "PLUS", value: 1 }, ], - hiddenStats: [], } satisfies UserCardData; function UserCardSection({ id }: { id: string }) { diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 65713e24c..baf74ac99 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -39,8 +39,6 @@ import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; import { resolveFutureMatchModes } from "../q-utils"; import styles from "./GroupCard.module.css"; -// xxx: red cross to indicate negative note left? - export function GroupCard({ group, action, diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index 80510d40d..31d7c204b 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -44,7 +44,6 @@ const FALLBACK_TIER = { isPlus: false, name: "IRON" } as const; const SECONDS_TILL_STALE = process.env.NODE_ENV === "development" || IS_E2E_TEST_RUN ? 1_000_000 : 1_800; -// xxx: probably just export sort static method that takes in groups and private notes. currently sorting is in several places class SendouQClass { groups; #recentMatches; diff --git a/app/features/top-search/XRankPlacementRepository.server.test.ts b/app/features/top-search/XRankPlacementRepository.server.test.ts index 0a600cb93..4441e76d4 100644 --- a/app/features/top-search/XRankPlacementRepository.server.test.ts +++ b/app/features/top-search/XRankPlacementRepository.server.test.ts @@ -247,6 +247,42 @@ describe("refreshTenStarWeapons", () => { }); }); +describe("verifiedPeakXpByUserId", () => { + beforeEach(() => { + placementCounter = 0; + dbReset(); + }); + + afterEach(() => { + dbReset(); + }); + + test("reports a linked player's peak xp", async () => { + const userId = await createUser("user1"); + + expect( + await XRankPlacementRepository.verifiedPeakXpByUserId(userId), + ).toBeNull(); + + await db + .insertInto("SplatoonPlayer") + .values({ + userId, + splId: "spl-1", + peakXp: JSON.stringify({ + overall: 2800, + takoroka: 2800, + tentatek: null, + }), + }) + .execute(); + + expect(await XRankPlacementRepository.verifiedPeakXpByUserId(userId)).toBe( + 2800, + ); + }); +}); + describe("refreshTenStarWeapons with userId", () => { beforeEach(() => { placementCounter = 0; diff --git a/app/features/top-search/XRankPlacementRepository.server.ts b/app/features/top-search/XRankPlacementRepository.server.ts index 79f1b12b8..036f6c6e5 100644 --- a/app/features/top-search/XRankPlacementRepository.server.ts +++ b/app/features/top-search/XRankPlacementRepository.server.ts @@ -13,6 +13,22 @@ export function unlinkPlayerByUserId(userId: number) { .execute(); } +/** + * Overall verified peak XP of the user's linked Splatoon player, or `null` when no player is linked. + * Used to bound how high a linked user may self-report their unverified peak XP. + */ +export async function verifiedPeakXpByUserId( + userId: number, +): Promise { + const player = await db + .selectFrom("SplatoonPlayer") + .select("SplatoonPlayer.peakXp") + .where("SplatoonPlayer.userId", "=", userId) + .executeTakeFirst(); + + return player?.peakXp?.overall ?? null; +} + function xRankPlacementsQueryBase() { return db .selectFrom("XRankPlacement") diff --git a/app/features/user-card/UserCardRepository.server.test.ts b/app/features/user-card/UserCardRepository.server.test.ts index 838cf90b5..07b8a5376 100644 --- a/app/features/user-card/UserCardRepository.server.test.ts +++ b/app/features/user-card/UserCardRepository.server.test.ts @@ -90,13 +90,37 @@ describe("UserCardRepository.userCards", () => { expect(card?.shortBio).toBe("hello"); expect(card?.banner).toMatchObject({ type: "COLOR", hexCode: "#ff4655" }); - expect(card?.hiddenStats).toEqual(["XP"]); - // xxx: or filter out at query time? - // the hidden stat is still present in `stats` (filtered out at render time) + // 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 UserCardRepository.userCards({ + userIds: [1], + viewerId: 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 }], @@ -129,23 +153,4 @@ describe("UserCardRepository.userCards", () => { expect(banner?.type).toBe("URL"); expect(banner).toHaveProperty("url"); }); - - it("reports a linked player's peak xp", async () => { - expect(await UserCardRepository.linkedPlayerPeakXp(1)).toBeNull(); - - await db - .insertInto("SplatoonPlayer") - .values({ - userId: 1, - splId: "spl-1", - peakXp: JSON.stringify({ - overall: 2800, - takoroka: 2800, - tentatek: null, - }), - }) - .execute(); - - expect(await UserCardRepository.linkedPlayerPeakXp(1)).toBe(2800); - }); }); diff --git a/app/features/user-card/UserCardRepository.server.ts b/app/features/user-card/UserCardRepository.server.ts index 9d2d8ed01..1b0ad1c35 100644 --- a/app/features/user-card/UserCardRepository.server.ts +++ b/app/features/user-card/UserCardRepository.server.ts @@ -16,6 +16,7 @@ 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 * as XRankPlacementRepository from "~/features/top-search/XRankPlacementRepository.server"; import type { StageId } from "~/modules/in-game-lists/types"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { @@ -43,11 +44,17 @@ export async function userCards({ userIds, viewerId, include, + includeHiddenStats = false, }: { userIds: Array; viewerId: number | null; /** 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 }> { if (userIds.length === 0) return { userCards: new Map() }; @@ -73,6 +80,7 @@ export async function userCards({ enrichUserCardData( cardData, bestSeasonResult(cardData.id, seasonResults), + includeHiddenStats, ), ); } @@ -80,27 +88,11 @@ export async function userCards({ return { userCards }; } -/** - * Overall verified peak XP of the user's linked Splatoon player, or `null` when no player is linked. - * Used to bound how high a linked user may self-report their unverified peak XP. - */ -// xxx: different file? -export async function linkedPlayerPeakXp( - userId: number, -): Promise { - const player = await db - .selectFrom("SplatoonPlayer") - .select("SplatoonPlayer.peakXp") - .where("SplatoonPlayer.userId", "=", userId) - .executeTakeFirst(); - - return player?.peakXp?.overall ?? null; -} - /** * 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 - * linked player's verified peak XP (to display the XP input's max hint). + * image (id + preview url, for the image field's default value), the self-reported peak XP, the + * hidden stat types (to pre-check the visibility toggles), and the linked player's verified peak XP + * (to display the XP input's max hint). */ export async function cardEditExtras(userId: number) { const row = await db @@ -108,6 +100,7 @@ export async function cardEditExtras(userId: number) { .select((eb) => [ "User.bannerImgId", "User.unverifiedPeakXP", + "User.hiddenCardStats", bannerImageUrl(eb).as("bannerImageUrl"), ]) .where("User.id", "=", userId) @@ -117,7 +110,9 @@ export async function cardEditExtras(userId: number) { bannerImgId: row?.bannerImgId ?? null, bannerImageUrl: row?.bannerImageUrl ?? null, unverifiedPeakXP: row?.unverifiedPeakXP ?? null, - linkedPlayerPeakXp: await linkedPlayerPeakXp(userId), + hiddenCardStats: row?.hiddenCardStats ?? [], + linkedPlayerPeakXp: + await XRankPlacementRepository.verifiedPeakXpByUserId(userId), }; } @@ -270,14 +265,18 @@ function privateNoteJson( if (viewerId === null) { return sql | null>`null`; } return jsonObjectFrom( eb .selectFrom("PrivateUserNote") - .select(["PrivateUserNote.text", "PrivateUserNote.sentiment"]) + .select([ + "PrivateUserNote.text", + "PrivateUserNote.sentiment", + "PrivateUserNote.updatedAt", + ]) .where("PrivateUserNote.authorId", "=", viewerId) .whereRef("PrivateUserNote.targetId", "=", "User.id"), ); @@ -446,7 +445,20 @@ function enrichUserCardData( seasonSkill, seasonTop, }: { seasonSkill: TieredSkill | undefined; seasonTop: number | null }, + includeHiddenStats: boolean, ): UserCardData { + const hiddenStats: Array = + 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, @@ -460,15 +472,9 @@ function enrichUserCardData( friendCode: cardData.friendCode, freeAgentPostId: cardData.freeAgentPostId, privateNote: cardData.privateNote, - hiddenStats: cardData.hiddenCardStats ?? [], - stats: userCardStats({ - div: cardData.div, - plusTier: cardData.plusTier, - xpVerified: cardData.xpVerified, - xpUnverified: cardData.xpUnverified, - seasonSkill, - seasonTop, - }), + stats: includeHiddenStats + ? stats + : stats.filter((stat) => !hiddenStats.includes(stat.type)), }; } diff --git a/app/features/user-card/actions/user-card.edit.server.ts b/app/features/user-card/actions/user-card.edit.server.ts new file mode 100644 index 000000000..2e90ee84c --- /dev/null +++ b/app/features/user-card/actions/user-card.edit.server.ts @@ -0,0 +1,106 @@ +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 linkedPeakXp = await XRankPlacementRepository.verifiedPeakXpByUserId( + user.id, + ); + if (data.unverifiedXpPoints > maxUnverifiedXp(linkedPeakXp)) { + return { + fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" }, + }; + } + } + + // xxx: just autovalidate and prevent input from the boundary + 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 { + return [ + data.hideXp ? ("XP" as const) : null, + data.hideDiv ? ("DIV" as const) : null, + ].filter((stat) => stat !== null); +} diff --git a/app/features/user-card/components/UserCard.module.css b/app/features/user-card/components/UserCard.module.css index b00564a66..ec69232fc 100644 --- a/app/features/user-card/components/UserCard.module.css +++ b/app/features/user-card/components/UserCard.module.css @@ -203,9 +203,20 @@ gap: var(--s-2); } +.noteHeaderGroup { + display: flex; + flex-direction: column; + line-height: 1.1; +} + .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); } diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index f07d899c4..0a5a4f402 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -18,6 +18,7 @@ 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"; @@ -68,6 +69,8 @@ const STAT_ORDER: Record = { * Viewer-relative friendship data (`isFriend` + `mutualFriends`) is lazy-loaded from the * `/user-card/:id/friendship` route the first time the card opens. */ + +// xxx: make click to open, arrows to scroll which one is selected (logical order on the page?) export function UserCard({ userId, data: dataProp, @@ -238,7 +241,7 @@ export function UserCard({ if (!open) setOpenedByTouch(false); }} isNonModal - placement="bottom" + placement="right" className={styles.popover} > !data.hiddenStats.includes(stat.type)) - .toSorted((a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type]); + const stats = data.stats.toSorted( + (a, b) => STAT_ORDER[a.type] - STAT_ORDER[b.type], + ); const editPageUrl = userCardEditPage({ returnTo: `${location.pathname}${location.search}`, @@ -458,7 +461,17 @@ function NoteView({ return (
- {t("user:card.privateNote")} +
+ {t("user:card.privateNote")} + {note ? ( + + ) : null} +
{note?.text ?

{note.text}

: null}
) : ( - + )}
); diff --git a/app/features/user-card/loaders/user-card.edit.server.ts b/app/features/user-card/loaders/user-card.edit.server.ts new file mode 100644 index 000000000..68b4248f7 --- /dev/null +++ b/app/features/user-card/loaders/user-card.edit.server.ts @@ -0,0 +1,28 @@ +import { requireUser } from "~/features/auth/core/user.server"; +import invariant from "~/utils/invariant"; +import * as UserCardRepository from "../UserCardRepository.server"; +import { maxUnverifiedXp } from "../user-card-utils"; + +export const loader = async () => { + const user = requireUser(); + + const [{ userCards }, extras] = await Promise.all([ + UserCardRepository.userCards({ + userIds: [user.id], + viewerId: user.id, + includeHiddenStats: true, + }), + UserCardRepository.cardEditExtras(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")), + maxUnverifiedXp: maxUnverifiedXp(extras.linkedPlayerPeakXp), + presentStats: card.stats.map((stat) => stat.type), + }; +}; diff --git a/app/features/user-card/routes/user-card.edit.tsx b/app/features/user-card/routes/user-card.edit.tsx index ece2b9c01..dc055f3b0 100644 --- a/app/features/user-card/routes/user-card.edit.tsx +++ b/app/features/user-card/routes/user-card.edit.tsx @@ -1,29 +1,26 @@ import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { - type ActionFunction, type MetaFunction, - redirect, useLoaderData, useSearchParams, } from "react-router"; import { Main } from "~/components/Main"; -import type { HideableUserCardStat, XRankPlacementRegion } from "~/db/tables"; -import { requireUser } from "~/features/auth/core/user.server"; +import type { XRankPlacementRegion } from "~/db/tables"; import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; import { existingImage } from "~/form/image-field"; -import { parseFormDataWithImages } from "~/form/parse.server"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; -import invariant from "~/utils/invariant"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; -import { userCardEditPage, userPage } from "~/utils/urls"; +import { userCardEditPage } from "~/utils/urls"; import { PRESET_COLORS } from "../../tier-list-maker/tier-list-maker-constants"; -import * as UserCardRepository from "../UserCardRepository.server"; -import { USER_CARD } from "../user-card-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"], }; @@ -35,129 +32,6 @@ export const meta: MetaFunction = (args) => { }); }; -// xxx: loader to different file, project convention -export const loader = async () => { - const user = requireUser(); - - const [{ userCards }, extras] = await Promise.all([ - UserCardRepository.userCards({ userIds: [user.id], viewerId: user.id }), - UserCardRepository.cardEditExtras(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")), - maxUnverifiedXp: maxUnverifiedXp(extras.linkedPlayerPeakXp), - presentStats: card.stats.map((stat) => stat.type), - }; -}; - -// xxx: action to different file, project convention -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 linkedPeakXp = await UserCardRepository.linkedPlayerPeakXp(user.id); - if (data.unverifiedXpPoints > maxUnverifiedXp(linkedPeakXp)) { - return { - fieldErrors: { unverifiedXpPoints: "forms:errors.unverifiedXpTooHigh" }, - }; - } - } - - // xxx: just autovalidate and prevent input from the boundary - 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 maxUnverifiedXp(linkedPeakXp: number | null) { - return typeof linkedPeakXp === "number" - ? linkedPeakXp + USER_CARD.MAX_UNVERIFIED_XP_ABOVE_LINKED_PLAYER - : USER_CARD.MAX_UNVERIFIED_XP_WITHOUT_LINKED_PLAYER; -} - -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 { - return [ - data.hideXp ? ("XP" as const) : null, - data.hideDiv ? ("DIV" as const) : null, - ].filter((stat) => stat !== null); -} - export default function UserCardEditPage() { const { t } = useTranslation(["user"]); const data = useLoaderData(); @@ -196,8 +70,8 @@ function defaultValues(data: Awaited>) { unverifiedXpDivision: (typeof peakXp?.takoroka === "number" ? "JPN" : "WEST") as XRankPlacementRegion, - hideXp: card.hiddenStats.includes("XP"), - hideDiv: card.hiddenStats.includes("DIV"), + hideXp: extras.hiddenCardStats.includes("XP"), + hideDiv: extras.hiddenCardStats.includes("DIV"), }; } diff --git a/app/features/user-card/user-card-types.ts b/app/features/user-card/user-card-types.ts index b2ffdaec3..be89b31cc 100644 --- a/app/features/user-card/user-card-types.ts +++ b/app/features/user-card/user-card-types.ts @@ -11,10 +11,11 @@ export interface UserCardData extends CommonUser { /** 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 | null; + privateNote: Pick< + Tables["PrivateUserNote"], + "text" | "sentiment" | "updatedAt" + > | null; stats: Array; - /** Stat types the user has chosen to hide; filtered out of `stats` at render time. */ - hiddenStats: Array; } /** diff --git a/app/features/user-card/user-card-utils.ts b/app/features/user-card/user-card-utils.ts new file mode 100644 index 000000000..a4990df8e --- /dev/null +++ b/app/features/user-card/user-card-utils.ts @@ -0,0 +1,7 @@ +import { USER_CARD } from "./user-card-constants"; + +export function maxUnverifiedXp(linkedPeakXp: number | null) { + return typeof linkedPeakXp === "number" + ? linkedPeakXp + USER_CARD.MAX_UNVERIFIED_XP_ABOVE_LINKED_PLAYER + : USER_CARD.MAX_UNVERIFIED_XP_WITHOUT_LINKED_PLAYER; +} diff --git a/app/features/user-page/components/MutualFriends.module.css b/app/features/user-page/components/MutualFriends.module.css index 6bb2ee086..e7dfedd58 100644 --- a/app/features/user-page/components/MutualFriends.module.css +++ b/app/features/user-page/components/MutualFriends.module.css @@ -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); diff --git a/app/features/user-page/components/MutualFriends.tsx b/app/features/user-page/components/MutualFriends.tsx index d8479b406..5b0591fdc 100644 --- a/app/features/user-page/components/MutualFriends.tsx +++ b/app/features/user-page/components/MutualFriends.tsx @@ -9,18 +9,23 @@ import styles from "./MutualFriends.module.css"; const MAX_VISIBLE_AVATARS = 5; -// xxx: for usercard do we even want popover there? or a different component, lighter data and no popover export function MutualFriends({ mutualFriends, + withoutPopover = false, }: { mutualFriends: Array; + /** 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 ( +
+ +
+ ); + } return (
@@ -28,24 +33,7 @@ export function MutualFriends({ trigger={
-
- {visibleFriends.map((friend) => ( - - ))} -
- {overflowCount > 0 ? ( - +{overflowCount} - ) : null} - - {t("user:mutualFriends.count", { - count: mutualFriends.length, - })} - +
} @@ -66,3 +54,33 @@ export function MutualFriends({
); } + +function AvatarStack({ mutualFriends }: { mutualFriends: Array }) { + const { t } = useTranslation(["user"]); + + const visibleFriends = mutualFriends.slice(0, MAX_VISIBLE_AVATARS); + const overflowCount = mutualFriends.length - MAX_VISIBLE_AVATARS; + + return ( + <> +
+ {visibleFriends.map((friend) => ( + + ))} +
+ {overflowCount > 0 ? ( + +{overflowCount} + ) : null} + + {t("user:mutualFriends.count", { + count: mutualFriends.length, + })} + + + ); +}