mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
519 lines
12 KiB
TypeScript
519 lines
12 KiB
TypeScript
import { Link, useFetcher } from "@remix-run/react";
|
|
import clsx from "clsx";
|
|
import React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
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 { EditIcon } from "~/components/icons/Edit";
|
|
import { TrashIcon } from "~/components/icons/Trash";
|
|
import { useUser } from "~/features/auth/core/user";
|
|
import * as Seasons from "~/features/mmr/core/Seasons";
|
|
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
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">
|
|
<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 isMounted = useIsMounted();
|
|
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">
|
|
{isMounted && <PostTimezonePill timezone={post.timezone} />}
|
|
{post.languages && (
|
|
<PostLanguagePill languages={post.languages} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Divider />
|
|
<div className="stack horizontal justify-between">
|
|
<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 isMounted = useIsMounted();
|
|
|
|
return (
|
|
<div
|
|
className={clsx("stack sm xs-row horizontal flex-wrap", {
|
|
invisible: !isMounted,
|
|
})}
|
|
>
|
|
{typeof timezone === "string" && isMounted && (
|
|
<PostTimezonePill timezone={timezone} />
|
|
)}
|
|
{!isMounted && <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)}>
|
|
<EditIcon />
|
|
{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={<TrashIcon className="small-icon" />}
|
|
>
|
|
{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>
|
|
);
|
|
}
|