User page seasons links as select (#2413)

This commit is contained in:
Kalle
2025-06-23 12:02:25 +03:00
committed by GitHub
parent 68ce0063e0
commit c5674962b6
25 changed files with 274 additions and 188 deletions

View File

@@ -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"]);

View File

@@ -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;
}

View File

@@ -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("");

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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);
}

View File

@@ -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 }),

View File

@@ -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 (

View File

@@ -252,6 +252,10 @@
}
}
.u__season__select {
--select-width: 125px;
}
.u__season__weapon-container {
display: flex;
align-items: center;

View File

@@ -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",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -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",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -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é",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "シーズン",
"seasons.noQ": "このプレイヤーは今シーズンSendouQをプレイしていません",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "武器",
"seasons.tabs.stages": "ステージ",
"seasons.tabs.self": "自分",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "",
"seasons.tabs.stages": "",
"seasons.tabs.self": "",

View File

@@ -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)",

View File

@@ -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": "По умолчанию",

View File

@@ -46,6 +46,7 @@
"search.pleaseLogIn.button": "",
"seasons": "赛季",
"seasons.noQ": "",
"seasons.noSeasons": "",
"seasons.tabs.weapons": "武器",
"seasons.tabs.stages": "地图",
"seasons.tabs.self": "自己",