mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 05:36:10 -05:00
Progress
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number | null> {
|
||||
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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<number>;
|
||||
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<number, UserCardData> }> {
|
||||
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<number | null> {
|
||||
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<Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"text" | "sentiment"
|
||||
"text" | "sentiment" | "updatedAt"
|
||||
> | 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<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,
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
106
app/features/user-card/actions/user-card.edit.server.ts
Normal file
106
app/features/user-card/actions/user-card.edit.server.ts
Normal file
@@ -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<HideableUserCardStat> {
|
||||
return [
|
||||
data.hideXp ? ("XP" as const) : null,
|
||||
data.hideDiv ? ("DIV" as const) : null,
|
||||
].filter((stat) => stat !== null);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UserCardStat["type"], number> = {
|
||||
* 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}
|
||||
>
|
||||
<CardContent
|
||||
@@ -333,9 +336,9 @@ function CardContent({
|
||||
|
||||
const [isNoteOpen, setIsNoteOpen] = React.useState(false);
|
||||
|
||||
const stats = data.stats
|
||||
.filter((stat) => !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 (
|
||||
<div className={styles.noteView}>
|
||||
<span className={styles.noteHeader}>{t("user:card.privateNote")}</span>
|
||||
<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
|
||||
@@ -568,7 +581,10 @@ function CardMutualFriends({
|
||||
{t("user:card.noMutualFriends")}
|
||||
</span>
|
||||
) : (
|
||||
<MutualFriends mutualFriends={friendship.mutualFriends} />
|
||||
<MutualFriends
|
||||
mutualFriends={friendship.mutualFriends}
|
||||
withoutPopover
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
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 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),
|
||||
};
|
||||
};
|
||||
@@ -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<HideableUserCardStat> {
|
||||
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<typeof loader>();
|
||||
@@ -196,8 +70,8 @@ function defaultValues(data: Awaited<ReturnType<typeof loader>>) {
|
||||
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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Tables["PrivateUserNote"], "text" | "sentiment"> | null;
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"text" | "sentiment" | "updatedAt"
|
||||
> | null;
|
||||
stats: Array<UserCardStat>;
|
||||
/** Stat types the user has chosen to hide; filtered out of `stats` at render time. */
|
||||
hiddenStats: Array<UserCardStat["type"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
7
app/features/user-card/user-card-utils.ts
Normal file
7
app/features/user-card/user-card-utils.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<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>
|
||||
@@ -28,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>
|
||||
}
|
||||
@@ -66,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user