sendou.ink/app/features/lfg/components/LFGPost.tsx
Kalle 3925b73d32 Replace useIsMounted with useHydrated
Strict improvement because we avoid the flash on clientside navigation.
One practical bug was scroll restoration between tournament teams list
and user page. When user pressed back they ended up at the bottom
of the page because the placeholder (smaller height than actual
content) rendered. With useHydrated this placeholder is no longer
rendered for client side navigations.
2026-03-28 07:44:52 +02:00

518 lines
12 KiB
TypeScript

import clsx from "clsx";
import { SquarePen, Trash } from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
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 { useUser } from "~/features/auth/core/user";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useHydrated } from "~/hooks/useHydrated";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampToDate } from "~/utils/dates";
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
import { hourDifferenceBetweenTimezones } from "../core/timezone";
import type { LFGLoaderData, TiersMap } from "../routes/lfg";
import styles from "./LFGPost.module.css";
type Post = LFGLoaderData["posts"][number];
export function LFGPost({
post,
tiersMap,
}: {
post: Post;
tiersMap: TiersMap;
}) {
if (post.team) {
return (
<TeamLFGPost post={{ ...post, team: post.team }} tiersMap={tiersMap} />
);
}
return <UserLFGPost post={post} tiersMap={tiersMap} />;
}
const USER_POST_EXPANDABLE_CRITERIA = 300;
function UserLFGPost({ post, tiersMap }: { post: Post; tiersMap: TiersMap }) {
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className={styles.wideLayout}>
<div className={styles.leftRow}>
<PostUserHeader
author={post.author}
includeWeapons={post.type !== "COACH_FOR_TEAM"}
/>
<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}
/>
</div>
<div>
<div className="stack horizontal justify-between items-center">
<PostTextTypeHeader type={post.type} />
{post.author.id === user?.id || isAdmin ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
expandableCriteria={USER_POST_EXPANDABLE_CRITERIA}
/>
</div>
</div>
);
}
function TeamLFGPost({
post,
tiersMap,
}: {
post: Post & { team: NonNullable<Post["team"]> };
tiersMap: TiersMap;
}) {
const isHydrated = useHydrated();
const user = useUser();
const isAdmin = useHasRole("ADMIN");
const [isExpanded, setIsExpanded] = React.useState(false);
return (
<div className={styles.wideLayout}>
<div className="stack md">
<div className="stack xs">
<div className="stack horizontal items-center justify-between">
<PostTeamLogoHeader team={post.team} />
<div className="stack horizontal items-center sm">
{isHydrated && <PostTimezonePill timezone={post.timezone} />}
{post.languages && (
<PostLanguagePill languages={post.languages} />
)}
</div>
</div>
<Divider />
<div className="stack horizontal justify-between items-center">
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
{post.author.id === user?.id ? (
<PostEditButton id={post.id} />
) : null}
</div>
</div>
{isExpanded ? (
<PostTeamMembersFull
team={post.team}
tiersMap={tiersMap}
postId={post.id}
/>
) : (
<PostTeamMembersPeek team={post.team} tiersMap={tiersMap} />
)}
</div>
<div>
<div className="stack horizontal justify-between">
<PostTextTypeHeader type={post.type} />
{post.author.id === user?.id || isAdmin ? (
<PostDeleteButton id={post.id} type={post.type} />
) : null}
</div>
<PostExpandableText
text={post.text}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
/>
</div>
</div>
);
}
function PostTeamLogoHeader({ team }: { team: NonNullable<Post["team"]> }) {
return (
<div className="stack horizontal sm items-center font-bold">
{team.avatarUrl ? <Avatar size="xs" url={team.avatarUrl} /> : null}
{team.name}
</div>
);
}
function PostTeamMembersPeek({
team,
tiersMap,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
}) {
return (
<div className="stack sm xs-row horizontal flex-wrap">
{team.members.map((member) => (
<PostTeamMember key={member.id} member={member} tiersMap={tiersMap} />
))}
</div>
);
}
function PostTeamMembersFull({
team,
tiersMap,
postId,
}: {
team: NonNullable<Post["team"]>;
tiersMap: TiersMap;
postId: number;
}) {
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>
))}
</div>
);
}
function PostTeamMember({
member,
tiersMap,
}: {
member: NonNullable<Post["team"]>["members"][number];
tiersMap: TiersMap;
}) {
const tiers = tiersMap.get(member.id);
const tier = tiers?.latest ?? tiers?.previous;
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>
</div>
);
}
function PostUserHeader({
author,
includeWeapons,
}: {
author: Post["author"];
includeWeapons: boolean;
}) {
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>
</div>
{includeWeapons ? (
<div className="stack horizontal sm">
{author.weaponPool.map(({ weaponSplId, isFavorite }) => (
<WeaponImage
key={weaponSplId}
weaponSplId={weaponSplId}
size={32}
variant={isFavorite ? "badge-5-star" : "badge"}
/>
))}
</div>
) : null}
</div>
);
}
function PostTime({
createdAt,
updatedAt,
}: {
createdAt: number;
updatedAt: number;
}) {
const { t } = useTranslation(["lfg"]);
const { formatDate, formatDistanceToNow } = useTimeFormat();
const createdAtDate = databaseTimestampToDate(createdAt);
const updatedAtDate = databaseTimestampToDate(updatedAt);
const overDayDifferenceBetween =
updatedAtDate.getTime() - createdAtDate.getTime() > 1000 * 60 * 60 * 24;
return (
<div className="text-lighter text-xs font-bold">
{formatDate(createdAtDate, {
month: "long",
day: "numeric",
})}{" "}
{overDayDifferenceBetween ? (
<div className="text-xxs">
<i>
({t("lfg:post.lastActive")}{" "}
{formatDistanceToNow(updatedAtDate, {
addSuffix: true,
})}
)
</i>
</div>
) : null}
</div>
);
}
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;
}) {
const isHydrated = useHydrated();
return (
<div
className={clsx("stack sm xs-row horizontal flex-wrap", {
invisible: !isHydrated,
})}
>
{typeof timezone === "string" && isHydrated && (
<PostTimezonePill timezone={timezone} />
)}
{!isHydrated && <PostTimezonePillPlaceholder />}
{typeof plusTier === "number" && (
<PostPlusServerPill plusTier={plusTier} />
)}
{tiers && <PostSkillPills tiers={tiers} />}
{typeof languages === "string" && (
<PostLanguagePill languages={languages} />
)}
{canEdit && <PostEditButton id={postId} />}
</div>
);
}
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);
const textColorClass = () => {
const absDiff = Math.abs(diff);
if (absDiff <= 3) {
return "text-success";
}
if (absDiff <= 6) {
return "text-warning";
}
return "text-error";
};
return (
<div title={timezone} className={clsx(styles.pill, textColorClass())}>
{diff === 0 ? "±" : ""}
{diff > 0 ? "+" : ""}
{diff}h
</div>
);
}
function PostLanguagePill({ languages }: { languages: string }) {
return (
<div className={styles.pill}>
{languages.replace(/,/g, " / ").toUpperCase()}
</div>
);
}
function PostTextTypeHeader({ type }: { type: Post["type"] }) {
const { t } = useTranslation(["lfg"]);
return (
<div className="text-xs text-lighter font-bold">
{t(`lfg:types.${type}`)}
</div>
);
}
function PostEditButton({ id }: { id: number }) {
const { t } = useTranslation(["common"]);
return (
<Link className={styles.editButton} to={lfgNewPostPage(id)}>
<SquarePen />
{t("common:actions.edit")}
</Link>
);
}
function PostDeleteButton({ id, type }: { id: number; type: Post["type"] }) {
const fetcher = useFetcher();
const { t } = useTranslation(["common", "lfg"]);
return (
<FormWithConfirm
dialogHeading={`Delete post (${(t(`lfg:types.${type}`) as any).toLowerCase()})?`}
fields={[
["id", id],
["_action", "DELETE_POST"],
]}
fetcher={fetcher}
>
<SendouButton
className="small-text"
variant="minimal-destructive"
size="small"
type="submit"
icon={<Trash />}
>
{t("common:actions.delete")}
</SendouButton>
</FormWithConfirm>
);
}
function PostExpandableText({
text,
isExpanded: _isExpanded,
setIsExpanded,
expandableCriteria,
}: {
text: string;
isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void;
expandableCriteria?: number;
}) {
const { t } = useTranslation(["common"]);
const isExpandable = !expandableCriteria || text.length > expandableCriteria;
const isExpanded = !isExpandable ? true : _isExpanded;
return (
<div
className={clsx({
[styles.textContainer]: !isExpanded,
[styles.textContainerExpanded]: isExpanded,
})}
>
<div className={styles.text}>{text}</div>
{isExpandable ? (
<SendouButton
onPress={() => setIsExpanded(!isExpanded)}
className={clsx([styles.showAllButton], {
[styles.showAllButtonExpanded]: isExpanded,
})}
variant="outlined"
size="small"
>
{isExpanded
? t("common:actions.showLess")
: t("common:actions.showMore")}
</SendouButton>
) : null}
{!isExpanded ? <div className={styles.textCut} /> : null}
</div>
);
}