mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-01 03:37:17 -05:00
* Tables * Clocks * Maplist preference selector * Fix SSR * Nav icon * RankedOrScrim * Map pool * Create group * Redirect logic * Persist map pool * Advance from preparing page * Rename query * Fix merge * Fix migration order * Seed groups * Find looking groups SQL * Renders something * More UI work * Back to 30min * Likes/dislikes * Always return own group * Fix like order * 3 tc/rm/cb -> 2 * Show only 3 weapons * Pass group size * Handle both liked and liked by same group * Fix SQL * Group preference frontend work * Morphing * Styling * Don't show group controls if not manager * Give/remove manager * Leave group * Leave with confirm * Delete likes when morphing groups * Clocks consistency * Remove bad invariant * Persist settings to local storage * Fix initial value flashing * Fix never resolving loading indicator * REFRESH_GROUP * Flip animations * Tweaks * Auto refresh logic * Groups of 4 seed * Reduce throwing * Load full groups initial * Create match * Match UI initial * Score reporter initial * Push footer down on match page * Score reporter knows when set ended * Score reporting untested * Show score after report * Align better * Look again with same group functionality * More migrations * Team on match page * Show confirmer before reporting score * Report weapons * Report weapos again by admin + skill changing * Handle no tiebreaker given to MapPool * Remove unranked * Remove support for "team id skill" * no-wrap -> nowrap * Preparing page work * Use common GroupCard component * Add some metas * MemberAdder in looking page * Fix GroupCard actions * Fix SZ only map list including other modes * Add season info * Prompt login * Joining team * Manage group on preparing page * Manage group on preparing page * Seed past matches * Add to seed * No map list preference when full group + fix expiry * Fix skill matchesCount calculation * Tiers initial work * Some progress on tiers * Tiering logic * MMR in group cards * Name to challenge * Team MMR * Big team rank icons * Adjust todos * Match score report with confirm * Allow regular members to report score * Handle reporting weapons edge cases * Add tier images * Improve GroupCard spacing * Refactor looking page * Looking mobile UI * Calculate skill only for current season * Divide groups visually when reporting weapons * Fix match page weapons sorting * Add cache to user skills+tier calculation * Admin report match score * Initial leaderboard * Cached leaderboard * Weapon category lb's * Populate SkillTeamUser in SendouQ * Team leaderboard filtered down * Add TODOs * Seasons initlal * Season weapons initial * Weapons stylized * Show rest weapons as + * Hide peak if same as current * Load matches SQL initial * Season matches UI initial * Take user id in account * Add weapons * Paginated matches * Fix pages count logic * Scroll top on data change * Day headers for matches * Link from user page to user seasons page * Summarize maps + ui initial * Map stats * Player info tabs * MMR chart * Chart adjustments * Handle basing team MMR on player MMR * Set initial MMR * Add info about discord to match page * Season support to tournaments * Get tournament skills as well for the graph * WIP * New team rating logic + misc other * tiered -> tiered.server * Update season starting time * TODOs * Add rules page * Hide elements correctly when off-season * Fix crash when only one player with skill * How-to video * Fix StartRank showing when not logged in * Make user leaderboard the default * Make Skill season non-nullable * Add suggested pass to match * Add rule * identifierToUserIds helper * Fix tiers not showing
368 lines
11 KiB
TypeScript
368 lines
11 KiB
TypeScript
import type {
|
|
LinksFunction,
|
|
LoaderArgs,
|
|
SerializeFrom,
|
|
V2_MetaFunction,
|
|
} from "@remix-run/node";
|
|
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
|
|
import { Avatar } from "~/components/Avatar";
|
|
import { Main } from "~/components/Main";
|
|
import { makeTitle } from "~/utils/strings";
|
|
import {
|
|
LEADERBOARDS_PAGE,
|
|
navIconUrl,
|
|
teamPage,
|
|
topSearchPlayerPage,
|
|
userPage,
|
|
userSeasonsPage,
|
|
userSubmittedImage,
|
|
} from "~/utils/urls";
|
|
import styles from "../../top-search/top-search.css";
|
|
import { userSPLeaderboard } from "../queries/userSPLeaderboard.server";
|
|
import type { SendouRouteHandle } from "~/utils/remix";
|
|
import {
|
|
type TeamSPLeaderboardItem,
|
|
teamSPLeaderboard,
|
|
} from "../queries/teamSPLeaderboard.server";
|
|
import React from "react";
|
|
import { LEADERBOARD_TYPES } from "../leaderboards-constants";
|
|
import { useTranslation } from "~/hooks/useTranslation";
|
|
import { i18next } from "~/modules/i18n";
|
|
import {
|
|
type XPLeaderboardItem,
|
|
allXPLeaderboard,
|
|
modeXPLeaderboard,
|
|
weaponXPLeaderboard,
|
|
} from "../queries/XPLeaderboard.server";
|
|
import { TierImage, WeaponImage } from "~/components/Image";
|
|
import {
|
|
weaponCategories,
|
|
type MainWeaponId,
|
|
type RankedModeShort,
|
|
} from "~/modules/in-game-lists";
|
|
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
|
import { allSeasons } from "~/features/mmr/season";
|
|
import {
|
|
addPlacementRank,
|
|
addTiers,
|
|
addWeapons,
|
|
filterByWeaponCategory,
|
|
oneEntryPerUser,
|
|
} from "../core/leaderboards.server";
|
|
import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
|
|
import { cachified } from "cachified";
|
|
import { cache, ttl } from "~/utils/cache.server";
|
|
import { HALF_HOUR_IN_MS } from "~/constants";
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
i18n: ["vods"],
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("leaderboards"),
|
|
href: LEADERBOARDS_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export const meta: V2_MetaFunction = (args) => {
|
|
const data = args.data as SerializeFrom<typeof loader> | null;
|
|
|
|
if (!data) return [];
|
|
|
|
return [
|
|
{ title: data.title },
|
|
{
|
|
name: "description",
|
|
content:
|
|
"Leaderboards of top Splatoon players ranked by their X Power and tournament results",
|
|
},
|
|
];
|
|
};
|
|
|
|
export const links: LinksFunction = () => {
|
|
return [{ rel: "stylesheet", href: styles }];
|
|
};
|
|
|
|
const TYPE_SEARCH_PARAM_KEY = "type";
|
|
|
|
export const loader = async ({ request }: LoaderArgs) => {
|
|
const t = await i18next.getFixedT(request);
|
|
const unvalidatedType = new URL(request.url).searchParams.get(
|
|
TYPE_SEARCH_PARAM_KEY
|
|
);
|
|
|
|
const type =
|
|
LEADERBOARD_TYPES.find((type) => type === unvalidatedType) ??
|
|
LEADERBOARD_TYPES[0];
|
|
|
|
const userLeaderboard = type.includes("USER")
|
|
? await cachified({
|
|
// TODO: add season here
|
|
key: `user-leaderboard-season-${0}`,
|
|
cache,
|
|
ttl: ttl(HALF_HOUR_IN_MS),
|
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
async getFreshValue() {
|
|
const leaderboard = userSPLeaderboard(0);
|
|
const withTiers = addTiers(leaderboard);
|
|
|
|
return addWeapons(withTiers, seasonPopularUsersWeapon(0));
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const teamLeaderboard =
|
|
type === "TEAM"
|
|
? await cachified({
|
|
// TODO: add season here
|
|
key: `team-leaderboard-season-${0}`,
|
|
cache,
|
|
ttl: ttl(HALF_HOUR_IN_MS),
|
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
async getFreshValue() {
|
|
const leaderboard = teamSPLeaderboard(0);
|
|
const filteredByUser = oneEntryPerUser(leaderboard);
|
|
|
|
return addPlacementRank(filteredByUser);
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const filteredLeaderboard =
|
|
userLeaderboard && type !== "USER"
|
|
? filterByWeaponCategory(
|
|
userLeaderboard,
|
|
type.split("-")[1] as (typeof weaponCategories)[number]["name"]
|
|
)
|
|
: userLeaderboard;
|
|
|
|
// TODO: season selection logic
|
|
return {
|
|
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
|
|
teamLeaderboard,
|
|
xpLeaderboard:
|
|
type === "XP-ALL"
|
|
? allXPLeaderboard()
|
|
: type.startsWith("XP-MODE")
|
|
? modeXPLeaderboard(type.split("-")[2] as RankedModeShort)
|
|
: type.startsWith("XP-WEAPON")
|
|
? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId)
|
|
: null,
|
|
title: makeTitle(t("pages.leaderboards")),
|
|
};
|
|
};
|
|
|
|
export default function LeaderboardsPage() {
|
|
const { t } = useTranslation(["common", "game-misc", "weapons"]);
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const data = useLoaderData<typeof loader>();
|
|
|
|
return (
|
|
<Main halfWidth className="stack lg">
|
|
<select
|
|
className="text-sm"
|
|
value={searchParams.get(TYPE_SEARCH_PARAM_KEY) ?? LEADERBOARD_TYPES[0]}
|
|
onChange={(e) =>
|
|
setSearchParams({ [TYPE_SEARCH_PARAM_KEY]: e.target.value })
|
|
}
|
|
>
|
|
{allSeasons(new Date()).map((season) => {
|
|
return (
|
|
<optgroup label={`SP - Season ${season}`} key={season}>
|
|
{LEADERBOARD_TYPES.filter((type) => !type.includes("XP")).map(
|
|
(type) => {
|
|
const userOrTeam = type.includes("USER") ? "USER" : "TEAM";
|
|
const category = weaponCategories.find((c) =>
|
|
type.includes(c.name)
|
|
)?.name;
|
|
|
|
return (
|
|
<option key={type} value={type}>
|
|
{t(`common:leaderboard.type.${userOrTeam}`)}
|
|
{category
|
|
? ` (${t(`common:weapon.category.${category}`)})`
|
|
: ""}
|
|
</option>
|
|
);
|
|
}
|
|
)}
|
|
</optgroup>
|
|
);
|
|
})}
|
|
<optgroup label="XP">
|
|
<option value="XP-ALL">{t(`common:leaderboard.type.XP-ALL`)}</option>
|
|
{rankedModesShort.map((mode) => {
|
|
return (
|
|
<option key={mode} value={`XP-MODE-${mode}`}>
|
|
{t(`game-misc:MODE_LONG_${mode}`)}
|
|
</option>
|
|
);
|
|
})}
|
|
</optgroup>
|
|
{weaponCategories.map((category) => {
|
|
return (
|
|
<optgroup
|
|
key={category.name}
|
|
label={`XP (${t(`common:weapon.category.${category.name}`)})`}
|
|
>
|
|
{category.weaponIds.map((weaponId) => {
|
|
return (
|
|
<option key={weaponId} value={`XP-WEAPON-${weaponId}`}>
|
|
{t(`weapons:MAIN_${weaponId}`)}
|
|
</option>
|
|
);
|
|
})}
|
|
</optgroup>
|
|
);
|
|
})}
|
|
</select>
|
|
{data.userLeaderboard ? (
|
|
<PlayersTable
|
|
entries={data.userLeaderboard}
|
|
showTiers={
|
|
!searchParams.get(TYPE_SEARCH_PARAM_KEY) ||
|
|
searchParams.get(TYPE_SEARCH_PARAM_KEY) === "USER"
|
|
}
|
|
/>
|
|
) : null}
|
|
{data.teamLeaderboard ? (
|
|
<TeamTable entries={data.teamLeaderboard} />
|
|
) : null}
|
|
{data.xpLeaderboard ? <XPTable entries={data.xpLeaderboard} /> : null}
|
|
{/* TODO: only when viewing current season */}
|
|
{!data.xpLeaderboard ? (
|
|
<div className="text-xs text-lighter">
|
|
Leaderboard is updated once every 30 minutes.
|
|
</div>
|
|
) : null}
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
function PlayersTable({
|
|
entries,
|
|
showTiers,
|
|
}: {
|
|
entries: NonNullable<SerializeFrom<typeof loader>["userLeaderboard"]>;
|
|
showTiers?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="placements__table">
|
|
{entries.map((entry) => {
|
|
return (
|
|
<React.Fragment key={entry.entryId}>
|
|
{entry.tier && showTiers ? (
|
|
<div className="placements__tier-header">
|
|
<TierImage tier={entry.tier} width={32} />
|
|
{entry.tier.name}
|
|
{entry.tier.isPlus ? "+" : ""}
|
|
</div>
|
|
) : null}
|
|
{/* TODO: dynamic season */}
|
|
<Link
|
|
to={userSeasonsPage({ user: entry, season: 0 })}
|
|
className="placements__table__row"
|
|
>
|
|
<div className="placements__table__inner-row">
|
|
<div className="placements__table__rank">
|
|
{entry.placementRank}
|
|
</div>
|
|
<div>
|
|
<Avatar size="xxs" user={entry} />
|
|
</div>
|
|
{entry.weaponSplId ? (
|
|
<WeaponImage
|
|
className="placements__table__weapon"
|
|
variant="build"
|
|
weaponSplId={entry.weaponSplId}
|
|
width={32}
|
|
height={32}
|
|
/>
|
|
) : null}
|
|
<div className="placements__table__name">
|
|
{entry.discordName}
|
|
</div>
|
|
<div className="placements__table__power">{entry.power}</div>
|
|
</div>
|
|
</Link>
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamTable({ entries }: { entries: TeamSPLeaderboardItem[] }) {
|
|
return (
|
|
<div className="placements__table">
|
|
{entries.map((entry) => {
|
|
return (
|
|
<div key={entry.entryId} className="placements__table__row">
|
|
<div className="placements__table__inner-row">
|
|
<div className="placements__table__rank">
|
|
{entry.placementRank}
|
|
</div>
|
|
{entry.team?.avatarImgUrl ? (
|
|
<Link
|
|
to={teamPage(entry.team.customUrl)}
|
|
title={entry.team.name}
|
|
>
|
|
<Avatar
|
|
size="xxs"
|
|
url={userSubmittedImage(entry.team.avatarImgUrl)}
|
|
className="placements__avatar"
|
|
/>
|
|
</Link>
|
|
) : null}
|
|
<div className="text-xs">
|
|
{entry.members.map((member, i) => {
|
|
return (
|
|
<React.Fragment key={member.id}>
|
|
<Link to={userPage(member)}>{member.discordName}</Link>
|
|
{i !== entry.members.length - 1 ? ", " : null}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="placements__table__power">{entry.power}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function XPTable({ entries }: { entries: XPLeaderboardItem[] }) {
|
|
return (
|
|
<div className="placements__table">
|
|
{entries.map((entry) => {
|
|
return (
|
|
<Link
|
|
to={topSearchPlayerPage(entry.playerId)}
|
|
key={entry.entryId}
|
|
className="placements__table__row"
|
|
>
|
|
<div className="placements__table__inner-row">
|
|
<div className="placements__table__rank">
|
|
{entry.placementRank}
|
|
</div>
|
|
{entry.discordId ? (
|
|
<Avatar size="xxs" user={entry as any} />
|
|
) : null}
|
|
<WeaponImage
|
|
className="placements__table__weapon"
|
|
variant="build"
|
|
weaponSplId={entry.weaponSplId}
|
|
width={32}
|
|
height={32}
|
|
/>
|
|
<div>{entry.name}</div>
|
|
<div className="placements__table__power">{entry.power}</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|