mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-08 12:16:12 -05:00
User page seasons links as select (#2413)
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SendouSelect,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
headGearBrandGrouped,
|
||||
shoesGearBrandGrouped,
|
||||
} from "~/modules/in-game-lists/gear-ids";
|
||||
import type { BrandId } from "~/modules/in-game-lists/types";
|
||||
import { brandImageUrl, gearImageUrl } from "~/utils/urls";
|
||||
|
||||
import styles from "./WeaponSelect.module.css";
|
||||
@@ -59,12 +57,9 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
|
||||
>
|
||||
{({ key, items: gear, brandId, idx }) => (
|
||||
<SendouSelectItemSection
|
||||
heading={
|
||||
<CategoryHeading
|
||||
brandId={brandId}
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
/>
|
||||
}
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
heading={t(`game-misc:BRAND_${brandId}` as any)}
|
||||
headingImgPath={brandImageUrl(brandId)}
|
||||
key={key}
|
||||
>
|
||||
{gear.map(({ id, name }) => (
|
||||
@@ -91,24 +86,6 @@ export function GearSelect<Clearable extends boolean | undefined = undefined>({
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryHeading({
|
||||
className,
|
||||
brandId,
|
||||
}: {
|
||||
className?: string;
|
||||
brandId: BrandId;
|
||||
}) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<div className={clsx(className, styles.categoryHeading)}>
|
||||
<Image path={brandImageUrl(brandId)} size={28} alt="" />
|
||||
{t(`game-misc:BRAND_${brandId}` as any)}
|
||||
<div className={styles.categoryDivider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useGearItems(type: GearType) {
|
||||
const { t } = useTranslation(["gear", "game-misc"]);
|
||||
|
||||
|
||||
@@ -18,31 +18,3 @@
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.categoryHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: bold;
|
||||
color: var(--text-lighter);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--fonts-xxs);
|
||||
padding-block-start: var(--s-2-5);
|
||||
padding-block-end: var(--s-1);
|
||||
padding-inline: var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.categoryDivider {
|
||||
background-color: var(--border);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
|
||||
.categoryHeading img {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
padding: var(--s-1);
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import type { Key } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -110,12 +109,15 @@ export function WeaponSelect<
|
||||
>
|
||||
{({ key, items: weapons, name, idx }) => (
|
||||
<SendouSelectItemSection
|
||||
heading={
|
||||
<CategoryHeading
|
||||
name={name}
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
/>
|
||||
heading={name}
|
||||
headingImgPath={
|
||||
name === "subs"
|
||||
? subWeaponImageUrl(SPLAT_BOMB_ID)
|
||||
: name === "specials"
|
||||
? specialWeaponImageUrl(TRIZOOKA_ID)
|
||||
: weaponCategoryUrl(name)
|
||||
}
|
||||
className={idx === 0 ? "pt-0-5-forced" : undefined}
|
||||
key={key}
|
||||
>
|
||||
{weapons.map(({ weapon, name }) => (
|
||||
@@ -167,31 +169,6 @@ export function WeaponSelect<
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryHeading({
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
name: (typeof weaponCategories)[number]["name"] | "subs" | "specials";
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const path = () => {
|
||||
if (name === "subs") return subWeaponImageUrl(SPLAT_BOMB_ID);
|
||||
if (name === "specials") return specialWeaponImageUrl(TRIZOOKA_ID);
|
||||
|
||||
return weaponCategoryUrl(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(className, styles.categoryHeading)}>
|
||||
<Image path={path()} size={28} alt="" />
|
||||
{t(`common:weapon.category.${name}`)}
|
||||
<div className={styles.categoryDivider} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFilteredWeaponItems(includeSubSpecial: boolean | undefined) {
|
||||
const items = useAllWeaponCategories(includeSubSpecial);
|
||||
const [filterValue, setFilterValue] = React.useState("");
|
||||
|
||||
@@ -151,3 +151,31 @@
|
||||
bottom: -17px;
|
||||
right: 9px;
|
||||
}
|
||||
|
||||
.categoryHeading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: bold;
|
||||
color: var(--text-lighter);
|
||||
text-transform: uppercase;
|
||||
font-size: var(--fonts-xxs);
|
||||
padding-block-start: var(--s-2-5);
|
||||
padding-block-end: var(--s-1);
|
||||
padding-inline: var(--s-1-5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.categoryDivider {
|
||||
background-color: var(--border);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
|
||||
.categoryHeading img {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
padding: var(--s-1);
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { SendouBottomTexts } from "~/components/elements/BottomTexts";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { ChevronUpDownIcon } from "~/components/icons/ChevronUpDown";
|
||||
import { Image } from "../Image";
|
||||
import { CrossIcon } from "../icons/Cross";
|
||||
import { SearchIcon } from "../icons/Search";
|
||||
import styles from "./Select.module.css";
|
||||
@@ -159,17 +160,27 @@ export function SendouSelectItem(props: SendouSelectItemProps) {
|
||||
}
|
||||
|
||||
interface SendouSelectItemSectionProps {
|
||||
heading: React.ReactNode;
|
||||
heading: string;
|
||||
headingImgPath?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SendouSelectItemSection({
|
||||
heading,
|
||||
headingImgPath,
|
||||
children,
|
||||
className,
|
||||
}: SendouSelectItemSectionProps) {
|
||||
return (
|
||||
<ListBoxSection>
|
||||
<Header>{heading}</Header>
|
||||
<Header className={clsx(className, styles.categoryHeading)}>
|
||||
{headingImgPath ? (
|
||||
<Image path={headingImgPath} size={28} alt="" />
|
||||
) : null}
|
||||
{heading}
|
||||
<div className={styles.categoryDivider} />
|
||||
</Header>
|
||||
{children}
|
||||
</ListBoxSection>
|
||||
);
|
||||
|
||||
@@ -231,3 +231,15 @@ function ignoreTeams({
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function seasonsParticipatedInByUserId(userId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("Skill")
|
||||
.select("season")
|
||||
.where("userId", "=", userId)
|
||||
.groupBy("season")
|
||||
.orderBy("season", "desc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => row.season);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { seasonAllMMRByUserId } from "~/features/mmr/queries/seasonAllMMRByUserId.server";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server";
|
||||
@@ -12,26 +12,38 @@ import { seasonSetWinrateByUserId } from "~/features/sendouq/queries/seasonSetWi
|
||||
import { seasonStagesByUserId } from "~/features/sendouq/queries/seasonStagesByUserId.server";
|
||||
import { seasonsMatesEnemiesByUserId } from "~/features/sendouq/queries/seasonsMatesEnemiesByUserId.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import {
|
||||
seasonsSearchParamsSchema,
|
||||
userParamsSchema,
|
||||
} from "../user-page-schemas";
|
||||
|
||||
export type UserSeasonsPageLoaderData = NonNullable<
|
||||
SerializeFrom<typeof loader>
|
||||
>;
|
||||
|
||||
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
const { identifier } = userParamsSchema.parse(params);
|
||||
const parsedSearchParams = seasonsSearchParamsSchema.safeParse(
|
||||
Object.fromEntries(new URL(request.url).searchParams),
|
||||
);
|
||||
const {
|
||||
info = "weapons",
|
||||
page = 1,
|
||||
season = Seasons.currentOrPrevious()!.nth,
|
||||
} = parsedSearchParams.success ? parsedSearchParams.data : {};
|
||||
|
||||
const user = notFoundIfFalsy(
|
||||
await UserRepository.identifierToUserId(identifier),
|
||||
);
|
||||
const seasonsParticipatedIn =
|
||||
await LeaderboardRepository.seasonsParticipatedInByUserId(user.id);
|
||||
|
||||
if (seasonsParticipatedIn.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
info = "weapons",
|
||||
page = 1,
|
||||
season = seasonsParticipatedIn[0],
|
||||
} = parsedSearchParams.success ? parsedSearchParams.data : {};
|
||||
|
||||
const { isAccurateTiers, userSkills } = _userSkills(season);
|
||||
const { tier, ordinal, approximate } = userSkills[user.id] ?? {
|
||||
@@ -41,6 +53,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
|
||||
return {
|
||||
seasonsParticipatedIn,
|
||||
currentOrdinal: !approximate ? ordinal : undefined,
|
||||
winrates: {
|
||||
maps: seasonMapWinrateByUserId({ season, userId: user.id }),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import {
|
||||
Link,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
@@ -12,6 +12,11 @@ import { Avatar } from "~/components/Avatar";
|
||||
import Chart from "~/components/Chart";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import {
|
||||
SendouSelect,
|
||||
SendouSelectItem,
|
||||
SendouSelectItemSection,
|
||||
} from "~/components/elements/Select";
|
||||
import {
|
||||
SendouTab,
|
||||
SendouTabList,
|
||||
@@ -43,7 +48,10 @@ import { cutToNDecimalPlaces, roundToNDecimalPlaces } from "~/utils/number";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { sendouQMatchPage, TIERS_PAGE, userSeasonsPage } from "~/utils/urls";
|
||||
|
||||
import { loader } from "../loaders/u.$identifier.seasons.server";
|
||||
import {
|
||||
loader,
|
||||
type UserSeasonsPageLoaderData,
|
||||
} from "../loaders/u.$identifier.seasons.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
export { loader };
|
||||
|
||||
@@ -56,13 +64,21 @@ export default function UserSeasonsPage() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const tabLink = (tab: string) =>
|
||||
`?info=${tab}&page=${data.matches.currentPage}&season=${data.season}`;
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="text-lg text-lighter font-semi-bold text-center mt-2">
|
||||
{t("user:seasons.noSeasons")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.matches.value.length === 0) {
|
||||
return (
|
||||
<div className="stack lg half-width">
|
||||
<SeasonHeader />
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
/>
|
||||
<div className="text-lg text-lighter font-semi-bold text-center mt-2">
|
||||
{t("user:seasons.noQ")}
|
||||
</div>
|
||||
@@ -70,17 +86,29 @@ export default function UserSeasonsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const tabLink = (tab: string) =>
|
||||
`?info=${tab}&page=${data.matches.currentPage}&season=${data.season}`;
|
||||
|
||||
return (
|
||||
<div className="stack lg half-width">
|
||||
<SeasonHeader />
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
/>
|
||||
{data.currentOrdinal ? (
|
||||
<div className="stack md">
|
||||
<Rank currentOrdinal={data.currentOrdinal} />
|
||||
<Rank
|
||||
currentOrdinal={data.currentOrdinal}
|
||||
seasonViewed={data.season}
|
||||
isAccurateTiers={data.isAccurateTiers}
|
||||
skills={data.skills}
|
||||
tier={data.tier}
|
||||
/>
|
||||
{data.winrates.maps.wins + data.winrates.maps.losses > 0 ? (
|
||||
<Winrates />
|
||||
<Winrates winrates={data.winrates} />
|
||||
) : null}
|
||||
{data.skills.length >= DAYS_WITH_SKILL_NEEDED_TO_SHOW_POWER_CHART ? (
|
||||
<PowerChart />
|
||||
<PowerChart skills={data.skills} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -121,47 +149,58 @@ export default function UserSeasonsPage() {
|
||||
</SubNav>
|
||||
<div className="u__season__info-container">
|
||||
{data.info.weapons ? <Weapons weapons={data.info.weapons} /> : null}
|
||||
{data.info.stages ? <Stages stages={data.info.stages} /> : null}
|
||||
{data.info.players ? <Players players={data.info.players} /> : null}
|
||||
{data.info.stages ? (
|
||||
<Stages stages={data.info.stages} seasonViewed={data.season} />
|
||||
) : null}
|
||||
{data.info.players ? (
|
||||
<Players players={data.info.players} seasonViewed={data.season} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Matches />
|
||||
<Matches matches={data.matches} seasonViewed={data.season} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonHeader() {
|
||||
const { t, i18n } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
function SeasonHeader({
|
||||
seasonViewed,
|
||||
seasonsParticipatedIn,
|
||||
}: {
|
||||
seasonViewed: number;
|
||||
seasonsParticipatedIn: number[];
|
||||
}) {
|
||||
const { i18n } = useTranslation(["user"]);
|
||||
const isMounted = useIsMounted();
|
||||
const { starts, ends } = Seasons.nthToDateRange(data.season);
|
||||
const { starts, ends } = Seasons.nthToDateRange(seasonViewed);
|
||||
const navigate = useNavigate();
|
||||
const options = useSeasonSelectOptions();
|
||||
|
||||
const isDifferentYears =
|
||||
new Date(starts).getFullYear() !== new Date(ends).getFullYear();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal xs">
|
||||
{Seasons.allStarted().map((s) => {
|
||||
const isActive = s === data.season;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`?season=${s}`}
|
||||
key={s}
|
||||
className={clsx("text-xl font-bold", {
|
||||
"text-lighter": !isActive,
|
||||
"text-main-forced": isActive,
|
||||
})}
|
||||
>
|
||||
{isActive
|
||||
? `${t("user:seasons.season")} `
|
||||
: t("user:seasons.season.short")}
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SendouSelect
|
||||
selectedKey={seasonViewed}
|
||||
onSelectionChange={(seasonNth) => navigate(`?season=${seasonNth}`)}
|
||||
items={options}
|
||||
className="u__season__select"
|
||||
popoverClassName="u__season__select"
|
||||
>
|
||||
{({ year, items, key }) => (
|
||||
<SendouSelectItemSection heading={year} key={key}>
|
||||
{items.map((item) => (
|
||||
<SendouSelectItem
|
||||
key={item.key}
|
||||
id={item.seasonNth}
|
||||
isDisabled={!seasonsParticipatedIn.includes(item.seasonNth)}
|
||||
>
|
||||
{item.name}
|
||||
</SendouSelectItem>
|
||||
))}
|
||||
</SendouSelectItemSection>
|
||||
)}
|
||||
</SendouSelect>
|
||||
<div className={clsx("text-sm text-lighter", { invisible: !isMounted })}>
|
||||
{isMounted ? (
|
||||
<>
|
||||
@@ -185,9 +224,42 @@ function SeasonHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
function Winrates() {
|
||||
function useSeasonSelectOptions() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
const seasonSelectItems = Seasons.allStarted().map((seasonNth) => ({
|
||||
seasonNth,
|
||||
key: seasonNth,
|
||||
name: `${t("user:seasons.season")} ${seasonNth}`,
|
||||
}));
|
||||
|
||||
const groupedSeasonItems = seasonSelectItems.reduce(
|
||||
(acc, item) => {
|
||||
const year = Seasons.nthToDateRange(item.seasonNth).starts.getFullYear();
|
||||
if (!acc[year]) {
|
||||
acc[year] = [];
|
||||
}
|
||||
acc[year].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, typeof seasonSelectItems>,
|
||||
);
|
||||
|
||||
return Object.entries(groupedSeasonItems)
|
||||
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA))
|
||||
.map(([year, items]) => ({
|
||||
year,
|
||||
items: items.sort((a, b) => b.seasonNth - a.seasonNth),
|
||||
key: year,
|
||||
}));
|
||||
}
|
||||
|
||||
function Winrates({
|
||||
winrates,
|
||||
}: {
|
||||
winrates: UserSeasonsPageLoaderData["winrates"];
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const winrate = (wins: number, losses: number) =>
|
||||
Math.round((wins / (wins + losses)) * 100);
|
||||
@@ -195,48 +267,57 @@ function Winrates() {
|
||||
return (
|
||||
<div className="stack horizontal sm">
|
||||
<div className="u__season__winrate">
|
||||
<span className="text-theme text-xxs">Sets</span>{" "}
|
||||
{data.winrates.sets.wins}
|
||||
{t("user:seasons.win.short")} {data.winrates.sets.losses}
|
||||
<span className="text-theme text-xxs">Sets</span> {winrates.sets.wins}
|
||||
{t("user:seasons.win.short")} {winrates.sets.losses}
|
||||
{t("user:seasons.loss.short")} (
|
||||
{winrate(data.winrates.sets.wins, data.winrates.sets.losses)}%)
|
||||
{winrate(winrates.sets.wins, winrates.sets.losses)}%)
|
||||
</div>
|
||||
<div className="u__season__winrate">
|
||||
<span className="text-theme text-xxs">Maps</span>{" "}
|
||||
{data.winrates.maps.wins}
|
||||
{t("user:seasons.win.short")} {data.winrates.maps.losses}
|
||||
<span className="text-theme text-xxs">Maps</span> {winrates.maps.wins}
|
||||
{t("user:seasons.win.short")} {winrates.maps.losses}
|
||||
{t("user:seasons.loss.short")} (
|
||||
{winrate(data.winrates.maps.wins, data.winrates.maps.losses)}%)
|
||||
{winrate(winrates.maps.wins, winrates.maps.losses)}%)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
function Rank({
|
||||
currentOrdinal,
|
||||
seasonViewed,
|
||||
tier,
|
||||
isAccurateTiers,
|
||||
skills,
|
||||
}: {
|
||||
currentOrdinal: number;
|
||||
seasonViewed: number;
|
||||
tier: UserSeasonsPageLoaderData["tier"];
|
||||
isAccurateTiers: UserSeasonsPageLoaderData["isAccurateTiers"];
|
||||
skills: UserSeasonsPageLoaderData["skills"];
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.data as UserPageLoaderData;
|
||||
|
||||
const maxOrdinal = Math.max(...data.skills.map((s) => s.ordinal));
|
||||
const maxOrdinal = Math.max(...skills.map((s) => s.ordinal));
|
||||
|
||||
const peakAndCurrentSame = currentOrdinal === maxOrdinal;
|
||||
|
||||
const topTenPlacement = playerTopTenPlacement({
|
||||
season: data.season,
|
||||
season: seasonViewed,
|
||||
userId: layoutData.user.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="stack horizontal items-center justify-center sm">
|
||||
<TierImage tier={data.tier} />
|
||||
<TierImage tier={tier} />
|
||||
<div>
|
||||
<Link to={TIERS_PAGE} className="text-xl font-bold">
|
||||
{data.tier.name}
|
||||
{data.tier.isPlus ? "+" : ""}
|
||||
{tier.name}
|
||||
{tier.isPlus ? "+" : ""}
|
||||
</Link>
|
||||
{!data.isAccurateTiers ? (
|
||||
{!isAccurateTiers ? (
|
||||
<div className="u__season__tentative">
|
||||
{t("user:seasons.tentative")}{" "}
|
||||
<SendouPopover
|
||||
@@ -263,7 +344,7 @@ function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
<TopTenPlayer
|
||||
small
|
||||
placement={topTenPlacement}
|
||||
season={data.season}
|
||||
season={seasonViewed}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -271,14 +352,16 @@ function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PowerChart() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
function PowerChart({
|
||||
skills,
|
||||
}: {
|
||||
skills: UserSeasonsPageLoaderData["skills"];
|
||||
}) {
|
||||
const chartOptions = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: "SP",
|
||||
data: data.skills.map((s) => {
|
||||
data: skills.map((s) => {
|
||||
return {
|
||||
primary: new Date(s.date),
|
||||
secondary: ordinalToSp(s.ordinal),
|
||||
@@ -286,7 +369,7 @@ function PowerChart() {
|
||||
}),
|
||||
},
|
||||
];
|
||||
}, [data]);
|
||||
}, [skills]);
|
||||
|
||||
return <Chart options={chartOptions as any} xAxis="localTime" />;
|
||||
}
|
||||
@@ -296,7 +379,7 @@ const WEAPONS_TO_SHOW = 9;
|
||||
function Weapons({
|
||||
weapons,
|
||||
}: {
|
||||
weapons: NonNullable<SerializeFrom<typeof loader>["info"]["weapons"]>;
|
||||
weapons: NonNullable<UserSeasonsPageLoaderData["info"]["weapons"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["user", "weapons"]);
|
||||
|
||||
@@ -345,11 +428,12 @@ function Weapons({
|
||||
}
|
||||
|
||||
function Stages({
|
||||
seasonViewed,
|
||||
stages,
|
||||
}: {
|
||||
stages: NonNullable<SerializeFrom<typeof loader>["info"]["stages"]>;
|
||||
seasonViewed: number;
|
||||
stages: NonNullable<UserSeasonsPageLoaderData["info"]["stages"]>;
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["user", "game-misc"]);
|
||||
const layoutData = atOrError(useMatches(), -2).data as UserPageLoaderData;
|
||||
|
||||
@@ -390,7 +474,7 @@ function Stages({
|
||||
>
|
||||
<StageWeaponUsageStats
|
||||
modeShort={mode}
|
||||
season={data.season}
|
||||
season={seasonViewed}
|
||||
stageId={id}
|
||||
userId={layoutData.user.id}
|
||||
/>
|
||||
@@ -493,11 +577,12 @@ function StageWeaponUsageStats(props: {
|
||||
|
||||
function Players({
|
||||
players,
|
||||
seasonViewed,
|
||||
}: {
|
||||
players: NonNullable<SerializeFrom<typeof loader>["info"]["players"]>;
|
||||
players: NonNullable<UserSeasonsPageLoaderData["info"]["players"]>;
|
||||
seasonViewed: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="stack md horizontal justify-center flex-wrap">
|
||||
@@ -511,7 +596,7 @@ function Players({
|
||||
return (
|
||||
<div key={player.user.id} className="stack">
|
||||
<Link
|
||||
to={userSeasonsPage({ user: player.user, season: data.season })}
|
||||
to={userSeasonsPage({ user: player.user, season: seasonViewed })}
|
||||
className="u__season__player-name"
|
||||
>
|
||||
<Avatar user={player.user} size="xs" className="mx-auto" />
|
||||
@@ -562,22 +647,27 @@ function WeaponCircle({
|
||||
);
|
||||
}
|
||||
|
||||
function Matches() {
|
||||
function Matches({
|
||||
seasonViewed,
|
||||
matches,
|
||||
}: {
|
||||
seasonViewed: number;
|
||||
matches: UserSeasonsPageLoaderData["matches"];
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const setPage = (page: number) => {
|
||||
setSearchParams({ page: String(page), season: String(data.season) });
|
||||
setSearchParams({ page: String(page), season: String(seasonViewed) });
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (data.matches.currentPage === 1) return;
|
||||
if (matches.currentPage === 1) return;
|
||||
ref.current?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}, [data.matches.currentPage]);
|
||||
}, [matches.currentPage]);
|
||||
|
||||
let lastDayRendered: number | null = null;
|
||||
return (
|
||||
@@ -585,7 +675,7 @@ function Matches() {
|
||||
<div ref={ref} />
|
||||
<div className="stack lg">
|
||||
<div className="stack">
|
||||
{data.matches.value.map((match) => {
|
||||
{matches.value.map((match) => {
|
||||
const day = databaseTimestampToDate(match.createdAt).getDate();
|
||||
const shouldRenderDateHeader = day !== lastDayRendered;
|
||||
lastDayRendered = day;
|
||||
@@ -616,12 +706,12 @@ function Matches() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{data.matches.pages > 1 ? (
|
||||
{matches.pages > 1 ? (
|
||||
<Pagination
|
||||
currentPage={data.matches.currentPage}
|
||||
pagesCount={data.matches.pages}
|
||||
nextPage={() => setPage(data.matches.currentPage + 1)}
|
||||
previousPage={() => setPage(data.matches.currentPage - 1)}
|
||||
currentPage={matches.currentPage}
|
||||
pagesCount={matches.pages}
|
||||
nextPage={() => setPage(matches.currentPage + 1)}
|
||||
previousPage={() => setPage(matches.currentPage - 1)}
|
||||
setPage={(page) => setPage(page)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -633,7 +723,7 @@ function Matches() {
|
||||
function Match({
|
||||
match,
|
||||
}: {
|
||||
match: SerializeFrom<typeof loader>["matches"]["value"][0];
|
||||
match: UserSeasonsPageLoaderData["matches"]["value"][0];
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const [, parentRoute] = useMatches();
|
||||
@@ -728,9 +818,7 @@ function MatchMembersRow({
|
||||
reserveWeaponSpace,
|
||||
}: {
|
||||
score: React.ReactNode;
|
||||
members: SerializeFrom<
|
||||
typeof loader
|
||||
>["matches"]["value"][0]["groupAlphaMembers"];
|
||||
members: UserSeasonsPageLoaderData["matches"]["value"][0]["groupAlphaMembers"];
|
||||
reserveWeaponSpace: boolean;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -252,6 +252,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.u__season__select {
|
||||
--select-width: 125px;
|
||||
}
|
||||
|
||||
.u__season__weapon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "Sæsoner",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Våben",
|
||||
"seasons.tabs.stages": "Baner",
|
||||
"seasons.tabs.self": "Self",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "Log in via Discord",
|
||||
"seasons": "Seasons",
|
||||
"seasons.noQ": "This user has not played SendouQ this season",
|
||||
"seasons.noSeasons": "This user has not participated in any seasons",
|
||||
"seasons.tabs.weapons": "Weapons",
|
||||
"seasons.tabs.stages": "Stages",
|
||||
"seasons.tabs.self": "Self",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "Temporadas",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Armas",
|
||||
"seasons.tabs.stages": "Mapas",
|
||||
"seasons.tabs.self": "Personal",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "Temporadas",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Armas",
|
||||
"seasons.tabs.stages": "Escenarios",
|
||||
"seasons.tabs.self": "Personal",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"discordExplanation": "Votre pseudo, votre photo de profil et vos comptes Youtube, Bluesky et Twitch viennent de votre compte Discord. Voir la <1>FAQ</1> pour plus d'informations.",
|
||||
"favoriteBadges": "Badge favori",
|
||||
"battlefy": "Nom du compte Battlefy",
|
||||
|
||||
"forms.showDiscordUniqueName": "Montrer le pseudo Discord",
|
||||
"forms.showDiscordUniqueName.info": "Show your unique Discord name ({{discordUniqueName}}) publicly?",
|
||||
"forms.commissionsOpen": "Commissions acceptées",
|
||||
@@ -23,7 +22,6 @@
|
||||
"forms.customName.info": "Si il n'est pas présent, votre pseudo discord est utilisé: \"{{discordName}}\"",
|
||||
"forms.country.search.placeholder": "Rechercher de pays",
|
||||
"forms.favoriteBadges.nonSupporter": "Devenez supporter pour définir l'ordre des badges qui apparaissent sur la première page",
|
||||
|
||||
"results.title": "Tout les résultats",
|
||||
"results.placing": "Placement",
|
||||
"results.team": "Équipe",
|
||||
@@ -35,7 +33,6 @@
|
||||
"results.highlights.explanation": "Sélectionnez les résultats que vous voulez mettre en avant",
|
||||
"results.button.showHighlights": "Montrer les highlights",
|
||||
"results.button.showAll": "Tout montrer",
|
||||
|
||||
"forms.errors.maxWeapons": "Nombre d'armes maximum atteint",
|
||||
"forms.errors.invalidCustomUrl.numbers": "Votre URL personnalisée ne peut pas contenir que des nombres",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Votre URL personnalisée ne peut pas contenir de caractères spéciaux",
|
||||
@@ -43,14 +40,13 @@
|
||||
"forms.errors.invalidSens": "La sensibilité du gyroscope ne peut pas être choisie si la sensibilité du stick droit ne l'est pas",
|
||||
"forms.info.customUrl": "Pour les Supporter patrons (& plus), les liens courts sont disponibles. Exemple: Au mieux de sendou.ink/u/sendou, snd.ink/sendou peut être utilisé.",
|
||||
"forms.info.battlefy": "Votre nom Battlefy est utiliser pour le seeding et la verification de certains tournois",
|
||||
|
||||
"search.info": "Recherchez avec le pseudo Discord ou Splatoon 3 du compte",
|
||||
"search.noResults": "Aucun utilisateur correspondant à '{{query}}' n'a été trouvé",
|
||||
"search.pleaseLogIn.header": "Veuillez vous connecter pour rechercher des utilisateurs",
|
||||
"search.pleaseLogIn.button": "Connectez-vous via Discord",
|
||||
|
||||
"seasons": "Saison",
|
||||
"seasons.noQ": "Ce joueur ne joue pas en SendouQ cette saison",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Armes",
|
||||
"seasons.tabs.stages": "Stages",
|
||||
"seasons.tabs.self": "Soi",
|
||||
@@ -67,7 +63,6 @@
|
||||
"seasons.clickARow": "Cliquez sur une ligne pour voir les statistiques d'utilisation des armes",
|
||||
"seasons.loading": "Chargement...",
|
||||
"seasons.matchBeingProcessed": "Le match n'a pas encore été terminé",
|
||||
|
||||
"builds.sorting.changeButton": "Changer le tri",
|
||||
"builds.sorting.header": "Modifier le tri des builds",
|
||||
"builds.sorting.backToDefaults": "Revenir par défaut",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "Stagioni",
|
||||
"seasons.noQ": "Questo utente non ha giocato SendouQ in questa stagione",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Armi",
|
||||
"seasons.tabs.stages": "Mappe",
|
||||
"seasons.tabs.self": "Sé",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "シーズン",
|
||||
"seasons.noQ": "このプレイヤーは今シーズンSendouQをプレイしていません",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "武器",
|
||||
"seasons.tabs.stages": "ステージ",
|
||||
"seasons.tabs.self": "自分",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "",
|
||||
"seasons.tabs.stages": "",
|
||||
"seasons.tabs.self": "",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "Temporadas",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Armas",
|
||||
"seasons.tabs.stages": "Mapas",
|
||||
"seasons.tabs.self": "Si mesmo(a)",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"discordExplanation": "Имя пользователя, аватар, ссылка на аккаунты YouTube, Bluesky и Twitch берутся из вашего аккаунта в Discord. Посмотрите <1>FAQ</1> для дополнительной информации.",
|
||||
"favoriteBadges": "Любимые награды",
|
||||
"battlefy": "Аккаунт Battlefy",
|
||||
|
||||
"forms.showDiscordUniqueName": "Показать пользовательское имя Discord",
|
||||
"forms.showDiscordUniqueName.info": "Показывать ваше уникальное Discord имя ({{discordUniqueName}})?",
|
||||
"forms.commissionsOpen": "Коммишены открыты",
|
||||
@@ -23,7 +22,6 @@
|
||||
"forms.customName.info": "Если пользовательское имя отсутствует, то будет использовано ваше имя в Discord: \"{{discordName}}\"",
|
||||
"forms.country.search.placeholder": "Искать страны",
|
||||
"forms.favoriteBadges.nonSupporter": "Станьте суппортером sendou.ink, чтобы выбрать какие награды и в каком порядке отображаются на первой странице",
|
||||
|
||||
"results.title": "Все результаты",
|
||||
"results.placing": "Место",
|
||||
"results.team": "Команда",
|
||||
@@ -35,7 +33,6 @@
|
||||
"results.highlights.explanation": "Выберите ваш избранный результат",
|
||||
"results.button.showHighlights": "Показать избранные",
|
||||
"results.button.showAll": "Показать все",
|
||||
|
||||
"forms.errors.maxWeapons": "Достигнут максимум",
|
||||
"forms.errors.invalidCustomUrl.numbers": "Пользовательский URL не может содержать только цифры",
|
||||
"forms.errors.invalidCustomUrl.strangeCharacter": "Пользовательский URL не может содержать особые символы",
|
||||
@@ -43,14 +40,13 @@
|
||||
"forms.errors.invalidSens": "Чувствительность наклона не может быть указана, если не указана чувствительность стика",
|
||||
"forms.info.customUrl": "Для меценатов (Supporter и выше) доступна короткая ссылка. Например, вместо sendou.ink/u/sendou может быть использована сссылка snd.ink/sendou.",
|
||||
"forms.info.battlefy": "Имя на Battlefy может быть использовано для посева и верификации в некоторых турнирах",
|
||||
|
||||
"search.info": "Поиск пользователей по имени Discord или Splatoon 3",
|
||||
"search.noResults": "По запросу '{{query}}' пользователь не найден",
|
||||
"search.pleaseLogIn.header": "Пожалуйста, войдите в аккаунт для поиска пользователя",
|
||||
"search.pleaseLogIn.button": "Войти с помощью Discord",
|
||||
|
||||
"seasons": "Сезоны",
|
||||
"seasons.noQ": "Данный пользователь не играл в SendouQ в этом сезоне",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "Оружие",
|
||||
"seasons.tabs.stages": "Арены",
|
||||
"seasons.tabs.self": "Я",
|
||||
@@ -67,7 +63,6 @@
|
||||
"seasons.clickARow": "Нажмите на ряд, чтобы посмотреть на статистику использованного оружия.",
|
||||
"seasons.loading": "Загрузка...",
|
||||
"seasons.matchBeingProcessed": "Данный матч ещё не был обработан",
|
||||
|
||||
"builds.sorting.changeButton": "Изменить сортировку",
|
||||
"builds.sorting.header": "Изменить сортировку сборок",
|
||||
"builds.sorting.backToDefaults": "По умолчанию",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"search.pleaseLogIn.button": "",
|
||||
"seasons": "赛季",
|
||||
"seasons.noQ": "",
|
||||
"seasons.noSeasons": "",
|
||||
"seasons.tabs.weapons": "武器",
|
||||
"seasons.tabs.stages": "地图",
|
||||
"seasons.tabs.self": "自己",
|
||||
|
||||
Reference in New Issue
Block a user