mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-12 22:11:06 -05:00
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.
518 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|