mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
385 lines
9.9 KiB
TypeScript
385 lines
9.9 KiB
TypeScript
import clsx from "clsx";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, useLoaderData } from "react-router";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Divider } from "~/components/Divider";
|
|
import { Image, WeaponImage } from "~/components/Image";
|
|
import { ArrowRightIcon } from "~/components/icons/ArrowRight";
|
|
import { BSKYLikeIcon } from "~/components/icons/BSKYLike";
|
|
import { BSKYReplyIcon } from "~/components/icons/BSKYReply";
|
|
import { BSKYRepostIcon } from "~/components/icons/BSKYRepost";
|
|
import { ExternalIcon } from "~/components/icons/External";
|
|
import { navItems } from "~/components/layout/nav-items";
|
|
import { Main } from "~/components/Main";
|
|
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
|
|
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
|
|
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
|
import * as Seasons from "~/features/mmr/core/Seasons";
|
|
import { useIsMounted } from "~/hooks/useIsMounted";
|
|
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
|
import styles from "~/styles/front.module.css";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import {
|
|
BLANK_IMAGE_URL,
|
|
LUTI_PAGE,
|
|
leaderboardsPage,
|
|
navIconUrl,
|
|
SENDOUQ_PAGE,
|
|
sqHeaderGuyImageUrl,
|
|
} from "~/utils/urls";
|
|
import { type LeaderboardEntry, loader } from "../loaders/index.server";
|
|
export { loader };
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["front", "game-misc"],
|
|
};
|
|
|
|
export default function FrontPage() {
|
|
return (
|
|
<Main className={styles.frontPageContainer}>
|
|
<LeagueBanner />
|
|
<SeasonBanner />
|
|
<SplatoonRotations />
|
|
<ResultHighlights />
|
|
<DiscoverFeatures />
|
|
<ChangelogList />
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function SeasonBanner() {
|
|
const { t } = useTranslation(["front"]);
|
|
const season = Seasons.next(new Date()) ?? Seasons.currentOrPrevious()!;
|
|
const _previousSeason = Seasons.previous();
|
|
const isMounted = useIsMounted();
|
|
const { formatDate } = useTimeFormat();
|
|
|
|
const isInFuture = new Date() < season.starts;
|
|
const isShowingPreviousSeason = _previousSeason?.nth === season.nth;
|
|
|
|
if (isShowingPreviousSeason) return null;
|
|
|
|
return (
|
|
<div className="stack xs">
|
|
<Link to={SENDOUQ_PAGE} className={styles.seasonBanner}>
|
|
<div className={styles.seasonBannerHeader}>
|
|
{t("front:sq.season", { nth: season.nth })}
|
|
</div>
|
|
{isMounted ? (
|
|
<div className={styles.seasonBannerDates}>
|
|
{formatDate(season.starts, {
|
|
month: "long",
|
|
day: "numeric",
|
|
})}{" "}
|
|
-{" "}
|
|
{formatDate(season.ends, {
|
|
month: "long",
|
|
day: "numeric",
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className={clsx(styles.seasonBannerDates, "invisible")}>X</div>
|
|
)}
|
|
<Image
|
|
className={styles.seasonBannerImg}
|
|
path={sqHeaderGuyImageUrl(season.nth)}
|
|
alt=""
|
|
/>
|
|
</Link>
|
|
<Link to={SENDOUQ_PAGE} className={styles.seasonBannerLink}>
|
|
<div className="stack horizontal xs items-center">
|
|
<Image path={navIconUrl("sendouq")} width={24} alt="" />
|
|
{isInFuture ? t("front:sq.prepare") : t("front:sq.participate")}
|
|
<ArrowRightIcon />
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LeagueBanner() {
|
|
const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON;
|
|
if (!showBannerFor) return null;
|
|
|
|
return (
|
|
<Link to={LUTI_PAGE} className={styles.lutiBanner}>
|
|
<Image path={navIconUrl("luti")} size={24} alt="" />
|
|
Registration now open for Leagues Under The Ink (LUTI) Season{" "}
|
|
{showBannerFor}!
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function ResultHighlights() {
|
|
const { t } = useTranslation(["front"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
// should not happen
|
|
if (
|
|
!data.leaderboards.team.length ||
|
|
!data.leaderboards.user.length ||
|
|
!data.tournaments.results.length
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const season = Seasons.currentOrPrevious()!;
|
|
|
|
const recentResults = (
|
|
<>
|
|
<h2
|
|
className={clsx(
|
|
styles.resultHighlightsTitle,
|
|
styles.resultHighlightsTitleTournaments,
|
|
)}
|
|
>
|
|
{t("front:showcase.results")}
|
|
</h2>
|
|
<div className={styles.tournamentCardsSpacer}>
|
|
{data.tournaments.results.map((tournament) => (
|
|
<TournamentCard
|
|
key={tournament.id}
|
|
tournament={tournament}
|
|
withRelativeTime
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={clsx(styles.resultHighlights, "overflow-x-auto scrollbar")}
|
|
>
|
|
<div className="stack sm text-center">
|
|
<h2 className={styles.resultHighlightsTitle}>
|
|
{t("front:leaderboards.topPlayers")}
|
|
</h2>
|
|
<Leaderboard
|
|
entries={data.leaderboards.user}
|
|
fullLeaderboardUrl={leaderboardsPage({
|
|
season: season.nth,
|
|
type: "USER",
|
|
})}
|
|
/>
|
|
</div>
|
|
<div className="stack sm text-center">
|
|
<h2 className={styles.resultHighlightsTitle}>
|
|
{t("front:leaderboards.topTeams")}
|
|
</h2>
|
|
<Leaderboard
|
|
entries={data.leaderboards.team}
|
|
fullLeaderboardUrl={leaderboardsPage({
|
|
season: season.nth,
|
|
type: "TEAM",
|
|
})}
|
|
/>
|
|
</div>
|
|
<div className="stack sm text-center mobile-hidden">
|
|
{recentResults}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={clsx(styles.resultHighlights, "overflow-x-auto scrollbar")}
|
|
>
|
|
<div className="stack sm text-center desktop-hidden">
|
|
{recentResults}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Leaderboard({
|
|
entries,
|
|
fullLeaderboardUrl,
|
|
}: {
|
|
entries: LeaderboardEntry[];
|
|
fullLeaderboardUrl: string;
|
|
}) {
|
|
const { t } = useTranslation(["front"]);
|
|
|
|
return (
|
|
<div className="stack xs items-center">
|
|
<div className={styles.leaderboard}>
|
|
{entries.map((entry, index) => (
|
|
<Link
|
|
to={entry.url}
|
|
key={entry.url}
|
|
className="stack sm horizontal items-center text-main-forced"
|
|
>
|
|
<div className="mx-1">{index + 1}</div>
|
|
<Avatar url={entry.avatarUrl ?? BLANK_IMAGE_URL} size="xs" />
|
|
<div className="stack items-start">
|
|
<div className={styles.leaderboardName}>{entry.name}</div>
|
|
<div className="text-xs font-semi-bold text-lighter">
|
|
{entry.power.toFixed(2)}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
<Link to={fullLeaderboardUrl} className={styles.leaderboardViewAll}>
|
|
<Image path={navIconUrl("leaderboards")} size={16} alt="" />
|
|
{t("front:leaderboards.viewFull")}
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const DISCOVER_EXCLUDED_ITEMS = new Set(["settings", "luti"]);
|
|
|
|
function DiscoverFeatures() {
|
|
const { t } = useTranslation(["front", "common"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
const filteredNavItems = navItems.filter(
|
|
(item) => !DISCOVER_EXCLUDED_ITEMS.has(item.name),
|
|
);
|
|
|
|
return (
|
|
<div className="stack md">
|
|
<Divider smallText className="text-uppercase text-xs font-bold">
|
|
{t("front:discover.header")}
|
|
</Divider>
|
|
{data.weaponPool && data.weaponPool.length > 0 ? (
|
|
<div className={styles.weaponPills}>
|
|
{data.weaponPool.map((weapon) => (
|
|
<Link
|
|
key={weapon.weaponSplId}
|
|
to={`?search=open&type=weapons&weapon=${weapon.weaponSplId}`}
|
|
className={styles.weaponPill}
|
|
>
|
|
<WeaponImage
|
|
weaponSplId={weapon.weaponSplId}
|
|
variant="badge"
|
|
size={32}
|
|
/>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<nav className={styles.discoverGrid}>
|
|
{filteredNavItems.map((item) => (
|
|
<Link
|
|
key={item.name}
|
|
to={`/${item.url}`}
|
|
className={styles.discoverGridItem}
|
|
>
|
|
<div className={styles.discoverGridItemImage}>
|
|
<Image
|
|
path={navIconUrl(item.name)}
|
|
height={32}
|
|
width={32}
|
|
alt=""
|
|
/>
|
|
</div>
|
|
<span>{t(`common:pages.${item.name}` as any)}</span>
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChangelogList() {
|
|
const { t } = useTranslation(["front"]);
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
if (data.changelog.length === 0) return null;
|
|
|
|
return (
|
|
<div className="stack md">
|
|
<Divider smallText className="text-uppercase text-xs font-bold">
|
|
{t("front:updates.header")}
|
|
</Divider>
|
|
{data.changelog.map((item) => (
|
|
<React.Fragment key={item.id}>
|
|
<ChangelogItem item={item} />
|
|
<br />
|
|
</React.Fragment>
|
|
))}
|
|
<a
|
|
href="https://bsky.app/hashtag/sendouink?author=sendou.ink"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="stack horizontal sm mx-auto text-xs font-bold"
|
|
>
|
|
{t("front:updates.viewPast")}{" "}
|
|
<ExternalIcon className={styles.externalLinkIcon} />
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ADMIN_PFP_URL =
|
|
"https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.webp?size=80";
|
|
|
|
function ChangelogItem({ item }: { item: Changelog.ChangelogItem }) {
|
|
return (
|
|
<div className="stack sm horizontal">
|
|
<Avatar size="sm" url={ADMIN_PFP_URL} />
|
|
<div className="whitespace-pre-wrap">
|
|
<div className="font-bold">
|
|
Sendou{" "}
|
|
<span className="text-xs text-lighter">{item.createdAtRelative}</span>
|
|
</div>
|
|
{item.text}
|
|
{item.images.length > 0 ? (
|
|
<div className="mt-4 stack horizontal sm flex-wrap">
|
|
{item.images.map((image) => (
|
|
<img
|
|
key={image.thumb}
|
|
src={image.thumb}
|
|
alt=""
|
|
className={styles.changeLogImg}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 stack xxl horizontal">
|
|
<BSKYIconLink count={item.stats.replies} postUrl={item.postUrl}>
|
|
<BSKYReplyIcon />
|
|
</BSKYIconLink>
|
|
<BSKYIconLink count={item.stats.reposts} postUrl={item.postUrl}>
|
|
<BSKYRepostIcon />
|
|
</BSKYIconLink>
|
|
<BSKYIconLink count={item.stats.likes} postUrl={item.postUrl}>
|
|
<BSKYLikeIcon />
|
|
</BSKYIconLink>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BSKYIconLink({
|
|
children,
|
|
count,
|
|
postUrl,
|
|
}: {
|
|
children: React.ReactNode;
|
|
count: number;
|
|
postUrl: string;
|
|
}) {
|
|
return (
|
|
<a
|
|
href={postUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={styles.changeLogIconButton}
|
|
>
|
|
{children}
|
|
<span
|
|
className={clsx({
|
|
invisible: count === 0,
|
|
})}
|
|
>
|
|
{count}
|
|
</span>
|
|
</a>
|
|
);
|
|
}
|