mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-15 07:43:44 -05:00
611 lines
16 KiB
TypeScript
611 lines
16 KiB
TypeScript
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 { useLayoutSize } from "~/hooks/useMainContentWidth";
|
|
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;
|
|
|
|
// beside the trigger there is no room for the card on a narrow viewport, so it is placed
|
|
// vertically instead where React Aria can shift it horizontally to keep it on-screen
|
|
const placement = useLayoutSize() === "mobile" ? "bottom" : "right";
|
|
|
|
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={placement} 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}
|
|
sentFriendRequest={friendship.sentFriendRequest}
|
|
incomingFriendRequestId={friendship.incomingFriendRequestId}
|
|
/>
|
|
) : 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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Friend request action on the card, submitting to the `/friends` route action. Normally sends a
|
|
* request and shows a checkmark once one is pending (server-known or just sent); when the shown
|
|
* user has already sent the viewer a request, the same add-friend press accepts it instead.
|
|
* Cancelling a pending request is done on the `/friends` page.
|
|
*/
|
|
function FriendRequestButton({
|
|
targetUserId,
|
|
sentFriendRequest,
|
|
incomingFriendRequestId,
|
|
}: {
|
|
targetUserId: number;
|
|
sentFriendRequest: boolean;
|
|
incomingFriendRequestId: number | null;
|
|
}) {
|
|
const { t } = useTranslation(["user"]);
|
|
const fetcher = useFetcher();
|
|
const previousStateRef = React.useRef(fetcher.state);
|
|
const acceptsIncomingRequest = incomingFriendRequestId !== null;
|
|
|
|
// Sending a request keeps this button mounted (it becomes the pending checkmark), so the
|
|
// success toast can wait for the server round-trip here. The accept path instead unmounts the
|
|
// button as soon as the revalidated friendship data arrives, which can race the toast render,
|
|
// so that toast is fired directly from the press handler below.
|
|
React.useEffect(() => {
|
|
if (
|
|
!acceptsIncomingRequest &&
|
|
previousStateRef.current === "submitting" &&
|
|
fetcher.state !== "submitting" &&
|
|
fetcher.data === null
|
|
) {
|
|
toastQueue.add(
|
|
{ message: t("user:card.friendRequestSent"), variant: "success" },
|
|
{ timeout: 5000 },
|
|
);
|
|
}
|
|
previousStateRef.current = fetcher.state;
|
|
}, [fetcher.state, fetcher.data, acceptsIncomingRequest, t]);
|
|
|
|
if (acceptsIncomingRequest) {
|
|
return (
|
|
<SendouButton
|
|
size="miniscule"
|
|
shape="circle"
|
|
icon={<UserPlus />}
|
|
isDisabled={fetcher.state !== "idle" || fetcher.data === null}
|
|
aria-label="Accept friend request"
|
|
onPress={() => {
|
|
toastQueue.add(
|
|
{
|
|
message: t("user:card.friendRequestAccepted"),
|
|
variant: "success",
|
|
},
|
|
{ timeout: 5000 },
|
|
);
|
|
fetcher.submit(
|
|
{
|
|
_action: "ACCEPT_REQUEST",
|
|
friendRequestId: incomingFriendRequestId,
|
|
},
|
|
{ method: "post", action: FRIENDS_PAGE },
|
|
);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const requestPending =
|
|
sentFriendRequest || 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;
|
|
}
|