sendou.ink/app/features/user-page/routes/u.$identifier.index.tsx
2026-03-21 15:19:32 +02:00

562 lines
14 KiB
TypeScript

import clsx from "clsx";
import { Pencil as EditIcon, Puzzle as PuzzleIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
href,
Link,
useLoaderData,
useMatches,
useOutletContext,
} from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { Flag } from "~/components/Flag";
import { Image, WeaponImage } from "~/components/Image";
import { BattlefyIcon } from "~/components/icons/Battlefy";
import { BskyIcon } from "~/components/icons/Bsky";
import { DiscordIcon } from "~/components/icons/Discord";
import { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { modesShort } from "~/modules/in-game-lists/modes";
import { countryCodeToTranslatedName } from "~/utils/i18n";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { rawSensToString } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
bskyUrl,
modeImageUrl,
navIconUrl,
teamPage,
topSearchPlayerPage,
} from "~/utils/urls";
import { MutualFriends } from "../components/MutualFriends";
import type { UserPageNavItem } from "../components/UserPageIconNav";
import { UserPageIconNav } from "../components/UserPageIconNav";
import { Widget } from "../components/Widget";
import { loader } from "../loaders/u.$identifier.index.server";
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
import styles from "../user-page.module.css";
import newStyles from "./u.$identifier.module.css";
export { loader };
export const handle: SendouRouteHandle = {
i18n: [
"badges",
"team",
"org",
"vods",
"lfg",
"builds",
"weapons",
"gear",
"game-badges",
],
};
export default function UserInfoPage() {
const data = useLoaderData<typeof loader>();
if (data.type === "new") {
return <NewUserInfoPage />;
}
return <OldUserInfoPage />;
}
function NewUserInfoPage() {
const { t, i18n } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
const { navItems } = useOutletContext<{ navItems: UserPageNavItem[] }>();
if (data.type !== "new") {
throw new Error("Expected new user data");
}
const mainWidgets = data.widgets.filter((w) => w.slot === "main");
const sideWidgets = data.widgets.filter((w) => w.slot === "side");
const isOwnPage = layoutData.user.id === user?.id;
return (
<div className={newStyles.container}>
<div className="stack sm">
<div className={newStyles.header}>
<Avatar user={layoutData.user} size="xmd" />
<div className={newStyles.userInfo}>
<div className={newStyles.nameGroup}>
<h1 className={newStyles.username}>{layoutData.user.username}</h1>
<ProfileSubtitle
inGameName={layoutData.user.inGameName}
pronouns={layoutData.user.pronouns}
plusTier={layoutData.user.plusTier}
country={layoutData.user.country}
language={i18n.language}
/>
</div>
</div>
<div className={newStyles.desktopIconNav}>
<UserPageIconNav items={navItems} />
</div>
</div>
<MutualFriends mutualFriends={layoutData.mutualFriends} />
</div>
{isOwnPage ? (
<div className={newStyles.editButtons}>
<LinkButton
to={href("/u/:identifier/edit-widgets", {
identifier:
layoutData.user.customUrl ?? layoutData.user.discordId,
})}
variant="outlined"
size="small"
icon={<PuzzleIcon />}
>
{t("user:widgets.edit")}
</LinkButton>
<LinkButton
to={href("/u/:identifier/edit", {
identifier:
layoutData.user.customUrl ?? layoutData.user.discordId,
})}
variant="outlined"
size="small"
icon={<EditIcon />}
>
{t("user:widgets.editProfile")}
</LinkButton>
</div>
) : null}
<div className={newStyles.mobileIconNav}>
<UserPageIconNav items={navItems} />
</div>
<div className={clsx(newStyles.sideCarousel, "scrollbar")}>
{sideWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={newStyles.mainStack}>
{mainWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={newStyles.grid}>
<div className={newStyles.main}>
{mainWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
<div className={newStyles.side}>
{sideWidgets.map((widget) => (
<Widget key={widget.id} widget={widget} user={layoutData.user} />
))}
</div>
</div>
</div>
);
}
export function OldUserInfoPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
invariant(parentRoute);
const layoutData = parentRoute.data as UserPageLoaderData;
if (data.type !== "old") {
throw new Error("Expected old user data");
}
return (
<div className={styles.container}>
<div className="stack sm">
<div className={styles.avatarContainer}>
<Avatar user={layoutData.user} size="lg" className={styles.avatar} />
<div>
<h2 className={styles.name}>
<div>{layoutData.user.username}</div>
<div>
{data.user.country ? (
<Flag countryCode={data.user.country} tiny />
) : null}
</div>
</h2>
<TeamInfo />
</div>
<div className={styles.socials}>
{data.user.twitch ? (
<SocialLink type="twitch" identifier={data.user.twitch} />
) : null}
{data.user.youtubeId ? (
<SocialLink type="youtube" identifier={data.user.youtubeId} />
) : null}
{data.user.battlefy ? (
<SocialLink type="battlefy" identifier={data.user.battlefy} />
) : null}
{data.user.bsky ? (
<SocialLink type="bsky" identifier={data.user.bsky} />
) : null}
</div>
</div>
<div className="stack items-center">
<MutualFriends mutualFriends={layoutData.mutualFriends} />
</div>
</div>
<ExtraInfos />
<WeaponPool />
<TopPlacements />
<BadgeDisplay badges={data.user.badges} key={layoutData.user.id} />
{data.user.bio && <article>{data.user.bio}</article>}
</div>
);
}
function TeamInfo() {
const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (!data.user.team) return null;
return (
<div className="stack horizontal sm">
<Link
to={teamPage(data.user.team.customUrl)}
className={styles.team}
data-testid="main-team-link"
>
{data.user.team.avatarUrl ? (
<img
alt=""
src={data.user.team.avatarUrl}
width={32}
height={32}
className="rounded-full"
/>
) : null}
<div>
{data.user.team.name}
{data.user.team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${data.user.team.userTeamRole}`)}
</div>
) : null}
</div>
</Link>
<SecondaryTeamsPopover />
</div>
);
}
function SecondaryTeamsPopover() {
const { t } = useTranslation(["team"]);
const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.secondaryTeams.length === 0) return null;
return (
<SendouPopover
trigger={
<SendouButton
className="focus-text-decoration self-start"
variant="minimal"
size="small"
>
<span
className="text-sm font-bold text-main-forced"
data-testid="secondary-team-trigger"
>
+{data.user.secondaryTeams.length}
</span>
</SendouButton>
}
>
<div className="stack sm">
{data.user.secondaryTeams.map((team) => (
<div
key={team.customUrl}
className="stack horizontal md items-center"
>
<Link
to={teamPage(team.customUrl)}
className={clsx(styles.team, "text-main-forced")}
>
{team.avatarUrl ? (
<img
alt=""
src={team.avatarUrl}
width={24}
height={24}
className="rounded-full"
/>
) : null}
{team.name}
</Link>
{team.userTeamRole ? (
<div className="text-xxs text-lighter font-bold">
{t(`team:roles.${team.userTeamRole}`)}
</div>
) : null}
</div>
))}
</div>
</SendouPopover>
);
}
interface SocialLinkProps {
type: "youtube" | "twitch" | "battlefy" | "bsky";
identifier: string;
}
export function SocialLink({
type,
identifier,
}: {
type: SocialLinkProps["type"];
identifier: string;
}) {
const href = () => {
switch (type) {
case "twitch":
return `https://www.twitch.tv/${identifier}`;
case "youtube":
return `https://www.youtube.com/channel/${identifier}`;
case "battlefy":
return `https://battlefy.com/users/${identifier}`;
case "bsky":
return bskyUrl(identifier);
default:
assertUnreachable(type);
}
};
return (
<a
className={clsx(styles.socialLink, {
[styles.socialLinkYoutube]: type === "youtube",
[styles.socialLinkTwitch]: type === "twitch",
[styles.socialLinkBattlefy]: type === "battlefy",
[styles.socialLinkBsky]: type === "bsky",
})}
href={href()}
>
<SocialLinkIcon type={type} />
</a>
);
}
function SocialLinkIcon({ type }: Pick<SocialLinkProps, "type">) {
switch (type) {
case "twitch":
return <TwitchIcon />;
case "youtube":
return <YouTubeIcon />;
case "battlefy":
return <BattlefyIcon />;
case "bsky":
return <BskyIcon />;
default:
assertUnreachable(type);
}
}
function ExtraInfos() {
const { t } = useTranslation(["user"]);
const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
const motionSensText =
typeof data.user.motionSens === "number"
? `${t("user:motion")} ${rawSensToString(data.user.motionSens)}`
: null;
const stickSensText =
typeof data.user.stickSens === "number"
? `${t("user:stick")} ${rawSensToString(data.user.stickSens)}`
: null;
if (
!data.user.inGameName &&
typeof data.user.stickSens !== "number" &&
!data.user.discordUniqueName &&
!data.user.plusTier
) {
return null;
}
return (
<div className={styles.extraInfos}>
<div className={styles.extraInfo}>#{data.user.id}</div>
{data.user.discordUniqueName && (
<div className={styles.extraInfo}>
<span className={styles.extraInfoHeading}>
<DiscordIcon />
</span>{" "}
{data.user.discordUniqueName}
</div>
)}
{data.user.pronouns && (
<div className={styles.extraInfo}>
<span className={styles.extraInfoHeading}>
{t("user:usesPronouns")}
</span>{" "}
{data.user.pronouns.subject}/{data.user.pronouns.object}
</div>
)}
{data.user.inGameName && (
<div className={styles.extraInfo}>
<span className={styles.extraInfoHeading}>{t("user:ign.short")}</span>{" "}
{data.user.inGameName}
</div>
)}
{typeof data.user.stickSens === "number" && (
<div className={styles.extraInfo}>
<span className={styles.extraInfoHeading}>{t("user:sens")}</span>{" "}
{[motionSensText, stickSensText].filter(Boolean).join(" / ")}
</div>
)}
{data.user.plusTier && (
<div className={styles.extraInfo}>
<Image path={navIconUrl("plus")} width={20} height={20} alt="" />{" "}
{data.user.plusTier}
</div>
)}
</div>
);
}
function WeaponPool() {
const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.weapons.length === 0) return null;
return (
<div className="stack horizontal sm justify-center">
{data.user.weapons.map((weapon, i) => {
return (
<div key={weapon.weaponSplId} className={styles.weapon}>
<WeaponImage
testId={`${weapon.weaponSplId}-${i + 1}`}
weaponSplId={weapon.weaponSplId}
variant={weapon.isFavorite ? "badge-5-star" : "badge"}
width={38}
height={38}
/>
</div>
);
})}
</div>
);
}
function ProfileSubtitle({
inGameName,
pronouns,
plusTier,
country,
language,
}: {
inGameName: string | null;
pronouns: { subject: string; object: string } | null;
plusTier: number | null;
country: string | null;
language: string;
}) {
const parts: React.ReactNode[] = [];
if (inGameName) {
parts.push(inGameName);
}
if (plusTier) {
parts.push(`+${plusTier}`);
}
if (pronouns) {
parts.push(`${pronouns.subject}/${pronouns.object}`);
}
if (country) {
parts.push(
<span key="country" className="stack horizontal xs items-center">
<Flag countryCode={country} tiny />
{countryCodeToTranslatedName({ countryCode: country, language })}
</span>,
);
}
if (parts.length === 0) return null;
return (
<div className={newStyles.subtitle}>
{parts.map((part, i) => (
<span key={i} className="stack horizontal xs items-center">
{i > 0 ? <span>·</span> : null}
{part}
</span>
))}
</div>
);
}
function TopPlacements() {
const data = useLoaderData<typeof loader>();
if (data.type !== "old") {
throw new Error("Expected old user data");
}
if (data.user.topPlacements.length === 0) return null;
return (
<Link
to={topSearchPlayerPage(data.user.topPlacements[0].playerId)}
className={styles.placements}
data-testid="placements-box"
>
{modesShort.map((mode) => {
const placement = data.user.topPlacements.find(
(placement) => placement.mode === mode,
);
if (!placement) return null;
return (
<div key={mode} className={styles.placementsMode}>
<Image path={modeImageUrl(mode)} alt="" width={24} height={24} />
<div>
{placement.rank} / {placement.power}
</div>
</div>
);
})}
</Link>
);
}