mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-01 00:24:13 -05:00
Exported images w/ SQ season summary downloadable (#3272)
This commit is contained in:
parent
83660a5b2f
commit
97619bdc05
|
|
@ -7,6 +7,7 @@ import styles from "./Ability.module.css";
|
|||
import { Image } from "./Image";
|
||||
|
||||
const sizeMap = {
|
||||
HUGE: 64,
|
||||
MAIN: 42,
|
||||
SUB: 32,
|
||||
SUBTINY: 26,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as React from "react";
|
|||
import type { Tables } from "~/db/tables";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { LRUCache } from "~/modules/cache";
|
||||
import { BLANK_IMAGE_URL, discordAvatarUrl } from "~/utils/urls";
|
||||
import { BLANK_IMAGE_URL, resolveAvatarUrl } from "~/utils/urls";
|
||||
import styles from "./Avatar.module.css";
|
||||
|
||||
const dimensions = {
|
||||
|
|
@ -132,19 +132,23 @@ export function Avatar({
|
|||
|
||||
const identiconSource = identiconInput ?? user?.discordId ?? "unknown";
|
||||
|
||||
const src = url
|
||||
? url
|
||||
: user?.customAvatarUrl && !isErrored
|
||||
? user.customAvatarUrl
|
||||
: user?.discordAvatar && !isErrored
|
||||
? discordAvatarUrl({
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
||||
})
|
||||
: isClient
|
||||
? generateIdenticon(identiconSource, dimensions[size], 7)
|
||||
: BLANK_IMAGE_URL;
|
||||
const userAvatarUrl = user
|
||||
? resolveAvatarUrl({
|
||||
customAvatarUrl: user.customAvatarUrl,
|
||||
discordId: user.discordId,
|
||||
discordAvatar: user.discordAvatar,
|
||||
size: size === "lg" || size === "xmd" ? "lg" : "sm",
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const avatarUrl = url ?? userAvatarUrl;
|
||||
|
||||
const src =
|
||||
avatarUrl && !isErrored
|
||||
? avatarUrl
|
||||
: isClient
|
||||
? generateIdenticon(identiconSource, dimensions[size], 7)
|
||||
: BLANK_IMAGE_URL;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.avatarWrapper, className)}>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import clsx from "clsx";
|
||||
import { Lock, MessageCircleMore, SquarePen, Trash } from "lucide-react";
|
||||
import {
|
||||
HardDriveDownload,
|
||||
Lock,
|
||||
MessageCircleMore,
|
||||
SquarePen,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
import {
|
||||
BuildGraphic,
|
||||
type BuildGraphicOwner,
|
||||
} from "~/features/img-export/components/BuildGraphic";
|
||||
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
BuildAbilitiesTuple,
|
||||
|
|
@ -28,6 +40,7 @@ import { Ability } from "./Ability";
|
|||
import styles from "./BuildCard.module.css";
|
||||
import { LinkButton, SendouButton } from "./elements/Button";
|
||||
import { SendouPopover } from "./elements/Popover";
|
||||
import { SendouSwitch } from "./elements/Switch";
|
||||
import { FormWithConfirm } from "./FormWithConfirm";
|
||||
import { Image } from "./Image";
|
||||
import { LocaleTime } from "./LocaleTime";
|
||||
|
|
@ -48,11 +61,21 @@ interface BuildProps {
|
|||
modes: ModeShort[] | null;
|
||||
weapons: Array<BuildWeaponWithTop500Info>;
|
||||
};
|
||||
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier">;
|
||||
owner?: Pick<UserWithPlusTier, "discordId" | "username" | "plusTier"> &
|
||||
Partial<
|
||||
Pick<BuildGraphicOwner, "customUrl" | "discordAvatar" | "customAvatarUrl">
|
||||
>;
|
||||
/** Set to false when the page context already shows the owner (e.g. their own builds page) */
|
||||
showOwner?: boolean;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||
export function BuildCard({
|
||||
build,
|
||||
owner,
|
||||
showOwner = true,
|
||||
canEdit = false,
|
||||
}: BuildProps) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
|
||||
|
||||
|
|
@ -100,15 +123,15 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
</h2>
|
||||
</div>
|
||||
<div className={styles.dateAuthorRow}>
|
||||
{owner && (
|
||||
{owner && showOwner ? (
|
||||
<>
|
||||
<Link to={userBuildsPage(owner)} className={styles.ownerLink}>
|
||||
{owner.username}
|
||||
</Link>
|
||||
<div>•</div>
|
||||
</>
|
||||
)}
|
||||
{owner?.plusTier ? (
|
||||
) : null}
|
||||
{owner?.plusTier && showOwner ? (
|
||||
<>
|
||||
<span>+{owner.plusTier}</span>
|
||||
<div>•</div>
|
||||
|
|
@ -180,6 +203,7 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
path={navIconUrl("analyzer")}
|
||||
/>
|
||||
</LinkButton>
|
||||
{owner ? <BuildImageExportDialog build={build} owner={owner} /> : null}
|
||||
{description ? (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
|
|
@ -229,6 +253,67 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function BuildImageExportDialog({
|
||||
build,
|
||||
owner,
|
||||
}: {
|
||||
build: BuildProps["build"];
|
||||
owner: BuildGraphicOwner;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [showTitle, setShowTitle] = React.useState(true);
|
||||
const [showAbilityPoints, setShowAbilityPoints] = React.useState(true);
|
||||
const [showAbilityChunks, setShowAbilityChunks] = React.useState(false);
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
trigger={
|
||||
<SendouButton
|
||||
shape="circle"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
icon={<HardDriveDownload />}
|
||||
className={styles.smallText}
|
||||
aria-label={t("common:imageExport.export")}
|
||||
/>
|
||||
}
|
||||
heading={t("common:imageExport.export")}
|
||||
filename={`build-${mySlugify(build.title)}`}
|
||||
qrCodePath={analyzerPage({
|
||||
weaponId: build.weapons[0].weaponSplId,
|
||||
abilities: build.abilities.flat(),
|
||||
})}
|
||||
settings={
|
||||
<>
|
||||
<SendouSwitch isSelected={showTitle} onChange={setShowTitle}>
|
||||
{t("common:imageExport.buildTitle")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showAbilityPoints}
|
||||
onChange={setShowAbilityPoints}
|
||||
>
|
||||
{t("common:imageExport.abilityPoints")}
|
||||
</SendouSwitch>
|
||||
<SendouSwitch
|
||||
isSelected={showAbilityChunks}
|
||||
onChange={setShowAbilityChunks}
|
||||
>
|
||||
{t("common:imageExport.abilityChunks")}
|
||||
</SendouSwitch>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<BuildGraphic
|
||||
build={build}
|
||||
owner={owner}
|
||||
showTitle={showTitle}
|
||||
showAbilityPoints={showAbilityPoints}
|
||||
showAbilityChunks={showAbilityChunks}
|
||||
/>
|
||||
</ImageExportDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RoundWeaponImage({ weapon }: { weapon: BuildWeaponWithTop500Info }) {
|
||||
const normalizedWeaponSplId = canonicalWeaponSplId(weapon.weaponSplId);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export function Flag({
|
|||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
className={clsx(`twf twf-${countryCode.toLowerCase()}`, {
|
||||
"twf-s": tiny,
|
||||
})}
|
||||
|
|
|
|||
14
app/db/seed/core/reseason.ts
Normal file
14
app/db/seed/core/reseason.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { db } from "~/db/sql";
|
||||
|
||||
/**
|
||||
* Moves every season stamped row (skills and the aggregated stats keyed off them)
|
||||
* to the given season. Concluding a match stamps its results with the season that
|
||||
* is current then, so matches backdated into an older season still leave their
|
||||
* results in the ongoing one — a seed that needs a season looking played out and
|
||||
* over has no other way to ask for one.
|
||||
*/
|
||||
export async function reseason(season: number) {
|
||||
await db.updateTable("Skill").set({ season }).execute();
|
||||
await db.updateTable("MapResult").set({ season }).execute();
|
||||
await db.updateTable("PlayerResult").set({ season }).execute();
|
||||
}
|
||||
|
|
@ -169,11 +169,7 @@ export function BuildCards({ data }: { data: SerializeFrom<typeof loader> }) {
|
|||
<BuildCard
|
||||
key={build.id}
|
||||
build={build}
|
||||
owner={
|
||||
build.owner
|
||||
? { ...build.owner, plusTier: build.plusTier }
|
||||
: undefined
|
||||
}
|
||||
owner={{ ...build.owner, plusTier: build.plusTier }}
|
||||
canEdit={false}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,20 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
|
|||
import { Table } from "~/components/Table";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
import {
|
||||
SeasonSummaryGraphic,
|
||||
type SeasonSummaryGraphicActivity,
|
||||
type SeasonSummaryGraphicBestSet,
|
||||
type SeasonSummaryGraphicStats,
|
||||
} from "~/features/img-export/components/SeasonSummaryGraphic";
|
||||
import {
|
||||
TournamentResultsGraphic,
|
||||
type TournamentResultsGraphicTeam,
|
||||
} from "~/features/img-export/components/TournamentResultsGraphic";
|
||||
import {
|
||||
TournamentRunGraphic,
|
||||
type TournamentRunGraphicMatch,
|
||||
} from "~/features/img-export/components/TournamentRunGraphic";
|
||||
import {
|
||||
Trophy,
|
||||
TrophyContextProvider,
|
||||
|
|
@ -66,7 +80,7 @@ import { EXAMPLE_TROPHY_MODEL } from "../example-trophy-model";
|
|||
import { formFieldsShowcaseSchema } from "../form-examples-schema";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user", "q"],
|
||||
i18n: ["user", "q", "calendar", "tournament"],
|
||||
};
|
||||
|
||||
export const SECTIONS = [
|
||||
|
|
@ -93,6 +107,21 @@ export const SECTIONS = [
|
|||
{ title: "Pagination", id: "pagination", component: PaginationSection },
|
||||
{ title: "Avatar", id: "avatar", component: AvatarSection },
|
||||
{ title: "User Card", id: "user-card", component: UserCardSection },
|
||||
{
|
||||
title: "Tournament Results Graphic",
|
||||
id: "tournament-results-graphic",
|
||||
component: TournamentResultsGraphicSection,
|
||||
},
|
||||
{
|
||||
title: "Tournament Run Graphic",
|
||||
id: "tournament-run-graphic",
|
||||
component: TournamentRunGraphicSection,
|
||||
},
|
||||
{
|
||||
title: "Season Summary Graphic",
|
||||
id: "season-summary-graphic",
|
||||
component: SeasonSummaryGraphicSection,
|
||||
},
|
||||
{
|
||||
title: "Form Messages",
|
||||
id: "form-messages",
|
||||
|
|
@ -1634,6 +1663,432 @@ function UserCardSection({ id }: { id: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
const RESULTS_GRAPHIC_IMG_ROOT =
|
||||
"https://sendou.nyc3.cdn.digitaloceanspaces.com";
|
||||
|
||||
const RESULTS_GRAPHIC_TEAMS: TournamentResultsGraphicTeam[] = [
|
||||
{
|
||||
placement: 1,
|
||||
name: "Besto Friendo",
|
||||
players: [
|
||||
{ name: "Yeti" },
|
||||
{ name: "まるお", countryCode: "JP" },
|
||||
{ name: "🪄", countryCode: "FR" },
|
||||
{ name: "Grey", countryCode: "FR" },
|
||||
],
|
||||
weapons: [40, 2070, 8010, 5030],
|
||||
},
|
||||
{
|
||||
placement: 2,
|
||||
name: "輝く",
|
||||
players: [
|
||||
{ name: "Ali", countryCode: "CA" },
|
||||
{ name: "w" },
|
||||
{ name: "へでる" },
|
||||
{ name: "メガチャーレム" },
|
||||
],
|
||||
weapons: [210, 40, 2010, 1120],
|
||||
},
|
||||
{
|
||||
placement: 3,
|
||||
name: "Small Bubbler",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/pickup-logo-867qQb75XxqndbJDbJzk3-1774655417856.webp`,
|
||||
players: [
|
||||
{ name: "Omegα" },
|
||||
{ name: "shadowind", countryCode: "US" },
|
||||
{ name: "Max", countryCode: "US" },
|
||||
{ name: "y0shell", countryCode: "GB" },
|
||||
],
|
||||
weapons: [5010, 2030, 1010, 0],
|
||||
},
|
||||
{
|
||||
placement: 4,
|
||||
name: "For fun.",
|
||||
players: [
|
||||
{ name: "Len. 🕷", countryCode: "VN" },
|
||||
{ name: "Jovan", countryCode: "NG" },
|
||||
{ name: "swish", countryCode: "US" },
|
||||
{ name: "prosper", countryCode: "NG" },
|
||||
],
|
||||
weapons: [240, 5040, 1030, 2070],
|
||||
},
|
||||
{
|
||||
placement: 5,
|
||||
name: "It’s too much",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/pickup-logo-1R_1LhuEk6jecMsRWwCl--1774679643780.webp`,
|
||||
players: [
|
||||
{ name: "Coolaceeeee ^_^", countryCode: "JP" },
|
||||
{ name: "illusion" },
|
||||
{ name: "sunni" },
|
||||
{ name: "Chiva", countryCode: "MX" },
|
||||
],
|
||||
weapons: [1001, 220, 40, 2010],
|
||||
},
|
||||
{
|
||||
placement: 5,
|
||||
name: "ezmd",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/Yu_tgElCa5D48CcyFPF3Y-1756077173431.webp`,
|
||||
players: [
|
||||
{ name: "Silver", countryCode: "FR" },
|
||||
{ name: "Devin", countryCode: "DE" },
|
||||
{ name: "kiki", countryCode: "IE" },
|
||||
{ name: "Reiyu", countryCode: "DO" },
|
||||
],
|
||||
weapons: [5011, 2000, 8020, 260],
|
||||
},
|
||||
{
|
||||
placement: 7,
|
||||
name: "healthy diet food groups",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/OYhoGpd7lIRyyBawAfRx9-1673646086021.webp`,
|
||||
players: [
|
||||
{ name: "zyf", countryCode: "US" },
|
||||
{ name: "Blopwher", countryCode: "US" },
|
||||
{ name: "Stans", countryCode: "US" },
|
||||
{ name: "Miner", countryCode: "ET" },
|
||||
],
|
||||
weapons: [1100, 2040, 10, 5000],
|
||||
},
|
||||
{
|
||||
placement: 7,
|
||||
name: "WIT CHECK",
|
||||
players: [
|
||||
{ name: "Andre", countryCode: "TT" },
|
||||
{ name: "Isabel T.J.", countryCode: "ES" },
|
||||
{ name: "Chara", countryCode: "US" },
|
||||
{ name: "Basil", countryCode: "US" },
|
||||
],
|
||||
weapons: [200, 1020, 2060, 5020],
|
||||
},
|
||||
];
|
||||
|
||||
function TournamentResultsGraphicSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Tournament Results Graphic</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Top 8 export image">
|
||||
<TournamentResultsGraphic
|
||||
tournamentId={3435}
|
||||
tournamentName="In The Zone 50"
|
||||
startTime={new Date(1774720800 * 1000)}
|
||||
tier={2}
|
||||
logoUrl={`${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-itz.png`}
|
||||
organization={{
|
||||
name: "sendou.ink",
|
||||
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/dBYwiLjlhVBwW-oyyNJkC-1721997877357.webp`,
|
||||
}}
|
||||
teams={RESULTS_GRAPHIC_TEAMS}
|
||||
teamsCount={32}
|
||||
playersCount={130}
|
||||
/>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const RUN_GRAPHIC_SWISS = "Day 1 - Swiss";
|
||||
const RUN_GRAPHIC_ALPHA = "Day 2 - Alpha Bracket";
|
||||
|
||||
const RUN_GRAPHIC_MATCHES: TournamentRunGraphicMatch[] = [
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[7], seed: 48 },
|
||||
ownScore: 2,
|
||||
opponentScore: 0,
|
||||
roundName: "Swiss 1",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[5], seed: 3 },
|
||||
ownScore: 2,
|
||||
opponentScore: 1,
|
||||
roundName: "Swiss 2",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[1], seed: 6 },
|
||||
ownScore: 1,
|
||||
opponentScore: 2,
|
||||
roundName: "Swiss 3",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[3], seed: 15 },
|
||||
ownScore: 2,
|
||||
opponentScore: 0,
|
||||
roundName: "Swiss 4",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[6], seed: 19 },
|
||||
ownScore: 2,
|
||||
opponentScore: 1,
|
||||
roundName: "Swiss 5",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[4], seed: 26 },
|
||||
ownScore: 2,
|
||||
opponentScore: 0,
|
||||
roundName: "Swiss 6",
|
||||
bracketName: RUN_GRAPHIC_SWISS,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[2], seed: 22 },
|
||||
ownScore: 2,
|
||||
opponentScore: 0,
|
||||
roundName: "WB Round 1",
|
||||
bracketName: RUN_GRAPHIC_ALPHA,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[5], seed: 3 },
|
||||
ownScore: 2,
|
||||
opponentScore: 1,
|
||||
roundName: "WB Round 2",
|
||||
bracketName: RUN_GRAPHIC_ALPHA,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[6], seed: 19 },
|
||||
ownScore: 2,
|
||||
opponentScore: 0,
|
||||
roundName: "WB Semis",
|
||||
bracketName: RUN_GRAPHIC_ALPHA,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[3], seed: 15 },
|
||||
ownScore: 2,
|
||||
opponentScore: 1,
|
||||
roundName: "WB Finals",
|
||||
bracketName: RUN_GRAPHIC_ALPHA,
|
||||
},
|
||||
{
|
||||
opponent: { ...RESULTS_GRAPHIC_TEAMS[1], seed: 6 },
|
||||
ownScore: 3,
|
||||
opponentScore: 1,
|
||||
roundName: "Grand Finals",
|
||||
bracketName: RUN_GRAPHIC_ALPHA,
|
||||
},
|
||||
];
|
||||
|
||||
function TournamentRunGraphicSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Tournament Run Graphic</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Team run export image">
|
||||
<TournamentRunGraphic
|
||||
tournamentId={3938}
|
||||
tournamentTeamId={73362}
|
||||
tournamentName="Low Ink June 2026"
|
||||
startTime={new Date(1781974800 * 1000)}
|
||||
tier={5}
|
||||
logoUrl={`${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-zWk5C1kvQtEWrd7d2c_KS-1735836299376.webp`}
|
||||
organization={{
|
||||
name: "Inkling Performance Labs",
|
||||
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp`,
|
||||
}}
|
||||
team={RESULTS_GRAPHIC_TEAMS[0]}
|
||||
seed={11}
|
||||
matches={RUN_GRAPHIC_MATCHES}
|
||||
teamsCount={74}
|
||||
playersCount={349}
|
||||
seriesWins={{
|
||||
totalCount: 4,
|
||||
first: {
|
||||
name: "Low Ink May 2023",
|
||||
startTime: new Date("2023-05-20T18:00:00Z"),
|
||||
},
|
||||
latest: {
|
||||
name: "Low Ink February 2026",
|
||||
startTime: new Date("2026-02-21T18:00:00Z"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const SEASON_SUMMARY_DAYS: Array<
|
||||
[date: string, sp: number, activity: SeasonSummaryGraphicActivity]
|
||||
> = [
|
||||
["2026-03-02", 1875.2, "sq"],
|
||||
["2026-03-03", 1922.7, "sq"],
|
||||
["2026-03-06", 1898.4, "sq"],
|
||||
["2026-03-08", 1961.3, "tournament"],
|
||||
["2026-03-10", 2004.9, "sq"],
|
||||
["2026-03-12", 1987.1, "sq"],
|
||||
["2026-03-14", 2043.6, "sq"],
|
||||
["2026-03-17", 2071.2, "sq"],
|
||||
["2026-03-19", 2055.8, "sq"],
|
||||
["2026-03-22", 2102.4, "both"],
|
||||
["2026-03-25", 2138.9, "sq"],
|
||||
["2026-03-26", 2117.3, "sq"],
|
||||
["2026-03-29", 2164.0, "tournament"],
|
||||
["2026-04-02", 2189.5, "sq"],
|
||||
["2026-04-05", 2151.2, "tournament"],
|
||||
["2026-04-06", 2208.8, "sq"],
|
||||
["2026-04-09", 2247.3, "sq"],
|
||||
["2026-04-12", 2231.6, "both"],
|
||||
["2026-04-15", 2278.1, "sq"],
|
||||
["2026-04-16", 2296.4, "sq"],
|
||||
["2026-04-19", 2263.7, "tournament"],
|
||||
["2026-04-21", 2312.9, "sq"],
|
||||
["2026-04-24", 2340.2, "sq"],
|
||||
["2026-04-26", 2371.8, "both"],
|
||||
["2026-04-28", 2355.4, "sq"],
|
||||
["2026-05-01", 2389.7, "sq"],
|
||||
["2026-05-03", 2410.8, "tournament"],
|
||||
["2026-05-05", 2384.2, "sq"],
|
||||
["2026-05-08", 2401.5, "sq"],
|
||||
["2026-05-10", 2368.9, "both"],
|
||||
["2026-05-11", 2352.6, "sq"],
|
||||
["2026-05-14", 2377.3, "sq"],
|
||||
["2026-05-16", 2341.5, "sq"],
|
||||
];
|
||||
|
||||
const SEASON_SUMMARY_BEST_SETS: SeasonSummaryGraphicBestSet[] = [
|
||||
{
|
||||
context: "SendouQ",
|
||||
ownScore: 4,
|
||||
opponentScore: 2,
|
||||
opponentSp: 2489.3,
|
||||
opponentPlayers: [
|
||||
{ name: "Yeti" },
|
||||
{ name: "まるお", countryCode: "JP" },
|
||||
{ name: "Silver", countryCode: "FR" },
|
||||
{ name: "Chara", countryCode: "US" },
|
||||
],
|
||||
},
|
||||
{
|
||||
context: "In The Zone 50",
|
||||
ownScore: 3,
|
||||
opponentScore: 2,
|
||||
opponentSp: 2451.0,
|
||||
opponentPlayers: [
|
||||
{ name: "zyf", countryCode: "US" },
|
||||
{ name: "Blopwher", countryCode: "US" },
|
||||
{ name: "Stans", countryCode: "US" },
|
||||
{ name: "Miner", countryCode: "ET" },
|
||||
],
|
||||
},
|
||||
{
|
||||
context: "SendouQ",
|
||||
ownScore: 4,
|
||||
opponentScore: 3,
|
||||
opponentSp: 2413.7,
|
||||
opponentPlayers: [
|
||||
{ name: "Andre", countryCode: "TT" },
|
||||
{ name: "Devin", countryCode: "DE" },
|
||||
{ name: "kiki", countryCode: "IE" },
|
||||
{ name: "Reiyu", countryCode: "DO" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SEASON_SUMMARY_STATS: SeasonSummaryGraphicStats = {
|
||||
tier: { name: "DIAMOND", isPlus: true },
|
||||
sp: 2341.5,
|
||||
setsWon: 41,
|
||||
setsLost: 18,
|
||||
mapsWon: 132,
|
||||
mapsLost: 77,
|
||||
longestWinStreak: 9,
|
||||
clutch: { won: 8, total: 12 },
|
||||
soloRank: 14,
|
||||
teamRank: {
|
||||
rank: 12,
|
||||
sp: 2502.4,
|
||||
mates: [
|
||||
{ name: "Grey", countryCode: "FR" },
|
||||
{ name: "sunni" },
|
||||
{ name: "Isabel T.J.", countryCode: "ES" },
|
||||
],
|
||||
team: {
|
||||
name: "Alliance Rogue",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/Yu_tgElCa5D48CcyFPF3Y-1756077173431.webp`,
|
||||
},
|
||||
},
|
||||
topMates: [
|
||||
{
|
||||
player: { name: "Grey", countryCode: "FR" },
|
||||
discordId: "289132480999030784",
|
||||
setsCount: 23,
|
||||
},
|
||||
{
|
||||
player: { name: "sunni" },
|
||||
discordId: "437750362188120075",
|
||||
setsCount: 17,
|
||||
},
|
||||
{
|
||||
player: { name: "まるお", countryCode: "JP" },
|
||||
discordId: "196628310288564224",
|
||||
setsCount: 9,
|
||||
},
|
||||
{
|
||||
player: { name: "Chara", countryCode: "US" },
|
||||
discordId: "455039198672814090",
|
||||
setsCount: 8,
|
||||
},
|
||||
{
|
||||
player: { name: "Silver", countryCode: "FR" },
|
||||
discordId: "224378380316540929",
|
||||
setsCount: 6,
|
||||
},
|
||||
{ player: { name: "Yeti" }, discordId: "153113232128507904", setsCount: 4 },
|
||||
],
|
||||
bestStage: { stageId: 14, winratePercentage: 78 },
|
||||
spProgression: SEASON_SUMMARY_DAYS.map(([date, sp]) => ({ date, sp })),
|
||||
activeDays: SEASON_SUMMARY_DAYS.map(([date, , activity]) => ({
|
||||
date,
|
||||
activity,
|
||||
})),
|
||||
bestSets: SEASON_SUMMARY_BEST_SETS,
|
||||
bestTournament: {
|
||||
name: "In The Zone 50",
|
||||
logoUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/tournament-logo-itz.png`,
|
||||
tier: 2,
|
||||
placement: 2,
|
||||
teamsCount: 32,
|
||||
},
|
||||
topWeapons: [
|
||||
{ weaponSplId: 40, usagePercentage: 46 },
|
||||
{ weaponSplId: 1001, usagePercentage: 31 },
|
||||
{ weaponSplId: 2070, usagePercentage: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
function SeasonSummaryGraphicSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
<SectionTitle id={id}>Season Summary Graphic</SectionTitle>
|
||||
|
||||
<div className="stack md">
|
||||
<ComponentRow label="Season summary export image">
|
||||
<SeasonSummaryGraphic
|
||||
user={{
|
||||
name: "Sendou",
|
||||
discordId: "79237403620945920",
|
||||
customUrl: "sendou",
|
||||
countryCode: "FI",
|
||||
avatarUrl: `${RESULTS_GRAPHIC_IMG_ROOT}/dBYwiLjlhVBwW-oyyNJkC-1721997877357.webp`,
|
||||
}}
|
||||
season={8}
|
||||
seasonDateRange={{
|
||||
starts: new Date("2026-03-02T17:00:00.000Z"),
|
||||
ends: new Date("2026-05-17T20:59:59.999Z"),
|
||||
}}
|
||||
stats={SEASON_SUMMARY_STATS}
|
||||
/>
|
||||
</ComponentRow>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function FormMessageSection({ id }: { id: string }) {
|
||||
return (
|
||||
<Section>
|
||||
|
|
|
|||
122
app/features/img-export/components/BuildGraphic.module.css
Normal file
122
app/features/img-export/components/BuildGraphic.module.css
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
.plusTier {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-extra);
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.buildTitle {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.weaponsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.weapon {
|
||||
position: relative;
|
||||
padding: var(--s-0-5);
|
||||
border-radius: 50%;
|
||||
background-color: var(--graphic-row-bg);
|
||||
}
|
||||
|
||||
.top500 {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.weaponName {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
|
||||
.gearGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, max-content);
|
||||
gap: var(--s-2) var(--s-3);
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
padding-block-end: var(--s-3);
|
||||
}
|
||||
|
||||
.noGear {
|
||||
grid-template-columns: repeat(2, max-content);
|
||||
}
|
||||
|
||||
.noAbilityPoints {
|
||||
padding-block-end: 0;
|
||||
}
|
||||
|
||||
.gear {
|
||||
background-color: var(--graphic-row-bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.subAbilities {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.subAbilitySegment {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.subAbilitySegmentIcons {
|
||||
display: flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.abilityPointsLabel {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
inset-inline: 0;
|
||||
margin-block-start: 5px;
|
||||
border-top: 1px solid
|
||||
color-mix(in oklch, var(--graphic-text-dim) 40%, transparent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: var(--weight-semi);
|
||||
line-height: 1.8;
|
||||
text-align: center;
|
||||
color: color-mix(in oklch, var(--graphic-text-dim) 70%, transparent);
|
||||
}
|
||||
|
||||
.abilityChunks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.abilityChunk {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.abilityChunkCount {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
251
app/features/img-export/components/BuildGraphic.tsx
Normal file
251
app/features/img-export/components/BuildGraphic.tsx
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ability } from "~/components/Ability";
|
||||
import { Image, ModeImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { AbilityPoints } from "~/features/build-analyzer/analyzer-types";
|
||||
import { getAbilityChunksMapAsArray } from "~/features/build-analyzer/core/abilityChunksCalc";
|
||||
import {
|
||||
apFromMap,
|
||||
buildToAbilityPoints,
|
||||
} from "~/features/build-analyzer/core/utils";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
BuildAbilitiesTuple,
|
||||
GearType,
|
||||
ModeShort,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { gearImageUrl, navIconUrl, resolveAvatarUrl } from "~/utils/urls";
|
||||
import styles from "./BuildGraphic.module.css";
|
||||
import {
|
||||
GRAPHIC_DATE_FORMAT_OPTIONS,
|
||||
GraphicContainer,
|
||||
GraphicHeader,
|
||||
} from "./Graphic";
|
||||
import graphicStyles from "./Graphic.module.css";
|
||||
|
||||
const BUILD_GRAPHIC_WIDTH = 380;
|
||||
|
||||
export interface BuildGraphicOwner {
|
||||
username: string;
|
||||
discordId: string;
|
||||
plusTier: number | null;
|
||||
customUrl?: Tables["User"]["customUrl"];
|
||||
discordAvatar?: Tables["User"]["discordAvatar"];
|
||||
customAvatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export function BuildGraphic({
|
||||
build,
|
||||
owner,
|
||||
showTitle = true,
|
||||
showAbilityPoints = true,
|
||||
showAbilityChunks = false,
|
||||
}: {
|
||||
build: Pick<
|
||||
Tables["Build"],
|
||||
| "title"
|
||||
| "description"
|
||||
| "clothesGearSplId"
|
||||
| "headGearSplId"
|
||||
| "shoesGearSplId"
|
||||
| "updatedAt"
|
||||
> & {
|
||||
abilities: BuildAbilitiesTuple;
|
||||
modes: ModeShort[] | null;
|
||||
weapons: Array<BuildWeaponWithTop500Info>;
|
||||
};
|
||||
owner: BuildGraphicOwner;
|
||||
showTitle?: boolean;
|
||||
showAbilityPoints?: boolean;
|
||||
showAbilityChunks?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["weapons"]);
|
||||
|
||||
const abilityPoints = buildToAbilityPoints(build.abilities);
|
||||
|
||||
const isNoGear = [
|
||||
build.headGearSplId,
|
||||
build.clothesGearSplId,
|
||||
build.shoesGearSplId,
|
||||
].some((id) => typeof id !== "number");
|
||||
|
||||
return (
|
||||
<GraphicContainer width={BUILD_GRAPHIC_WIDTH}>
|
||||
<GraphicHeader
|
||||
avatarUrl={resolveAvatarUrl({
|
||||
customAvatarUrl: owner.customAvatarUrl,
|
||||
discordId: owner.discordId,
|
||||
discordAvatar: owner.discordAvatar,
|
||||
size: "lg",
|
||||
})}
|
||||
identiconInput={owner.discordId}
|
||||
titleRow={
|
||||
<span className={graphicStyles.headerTitle}>
|
||||
{owner.username}
|
||||
{owner.plusTier ? (
|
||||
<span className={styles.plusTier}> +{owner.plusTier}</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
subtitle={
|
||||
<LocaleTime
|
||||
date={build.updatedAt}
|
||||
options={GRAPHIC_DATE_FORMAT_OPTIONS}
|
||||
className={graphicStyles.headerSubtitle}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
build.modes && build.modes.length > 0
|
||||
? build.modes.map((mode) => (
|
||||
<ModeImage key={mode} mode={mode} size={24} />
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
alignTrailingWithTitle
|
||||
/>
|
||||
{showTitle ? (
|
||||
<div className={styles.buildTitle}>{build.title}</div>
|
||||
) : null}
|
||||
<div className={styles.weaponsRow}>
|
||||
{build.weapons.map((weapon) => (
|
||||
<div key={weapon.weaponSplId} className={styles.weapon}>
|
||||
{weapon.isTop500 ? (
|
||||
<Image
|
||||
className={styles.top500}
|
||||
path={navIconUrl("xsearch")}
|
||||
alt=""
|
||||
height={26}
|
||||
width={26}
|
||||
/>
|
||||
) : null}
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponSplId}
|
||||
variant="badge"
|
||||
size={50}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{build.weapons.length === 1 ? (
|
||||
<div className={styles.weaponName}>
|
||||
{t(`weapons:MAIN_${build.weapons[0].weaponSplId}`)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.gearGrid, {
|
||||
[styles.noGear]: isNoGear,
|
||||
[styles.noAbilityPoints]: !showAbilityPoints,
|
||||
})}
|
||||
>
|
||||
<GearRow
|
||||
gearType="HEAD"
|
||||
abilities={build.abilities[0]}
|
||||
gearId={build.headGearSplId}
|
||||
abilityPoints={showAbilityPoints ? abilityPoints : null}
|
||||
/>
|
||||
<GearRow
|
||||
gearType="CLOTHES"
|
||||
abilities={build.abilities[1]}
|
||||
gearId={build.clothesGearSplId}
|
||||
abilityPoints={showAbilityPoints ? abilityPoints : null}
|
||||
/>
|
||||
<GearRow
|
||||
gearType="SHOES"
|
||||
abilities={build.abilities[2]}
|
||||
gearId={build.shoesGearSplId}
|
||||
abilityPoints={showAbilityPoints ? abilityPoints : null}
|
||||
/>
|
||||
</div>
|
||||
{showAbilityChunks ? (
|
||||
<AbilityChunksRow abilities={build.abilities} />
|
||||
) : null}
|
||||
{build.description ? (
|
||||
<div className={styles.description}>{build.description}</div>
|
||||
) : null}
|
||||
</GraphicContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function GearRow({
|
||||
gearType,
|
||||
abilities,
|
||||
gearId,
|
||||
abilityPoints,
|
||||
}: {
|
||||
gearType: GearType;
|
||||
abilities: AbilityType[];
|
||||
gearId: number | null;
|
||||
/** When null the ability point labels are hidden */
|
||||
abilityPoints: AbilityPoints | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["analyzer"]);
|
||||
|
||||
const [mainAbility, ...subAbilities] = abilities;
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof gearId === "number" ? (
|
||||
<Image
|
||||
height={80}
|
||||
width={80}
|
||||
alt=""
|
||||
path={gearImageUrl(gearType, gearId)}
|
||||
className={styles.gear}
|
||||
/>
|
||||
) : null}
|
||||
<Ability ability={mainAbility} size="HUGE" />
|
||||
<div className={styles.subAbilities}>
|
||||
{subAbilitySegments(subAbilities).map((segment, segmentIndex) => (
|
||||
<div key={segmentIndex} className={styles.subAbilitySegment}>
|
||||
<div className={styles.subAbilitySegmentIcons}>
|
||||
{Array.from({ length: segment.count }, (_, index) => (
|
||||
<Ability key={index} ability={segment.ability} size="MAIN" />
|
||||
))}
|
||||
</div>
|
||||
{abilityPoints ? (
|
||||
<div className={styles.abilityPointsLabel}>
|
||||
{apFromMap({ abilityPoints, ability: segment.ability })}
|
||||
{t("analyzer:abilityPoints.short")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AbilityChunksRow({ abilities }: { abilities: BuildAbilitiesTuple }) {
|
||||
const abilityChunks = getAbilityChunksMapAsArray(abilities);
|
||||
|
||||
if (abilityChunks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.abilityChunks}>
|
||||
{abilityChunks.map(([ability, count]) => (
|
||||
<div key={ability} className={styles.abilityChunk}>
|
||||
<Ability ability={ability} size="SUB" />
|
||||
<div className={styles.abilityChunkCount}>{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function subAbilitySegments(subAbilities: AbilityType[]) {
|
||||
const segments: Array<{ ability: AbilityType; count: number }> = [];
|
||||
|
||||
for (const ability of subAbilities) {
|
||||
const previousSegment = segments.at(-1);
|
||||
if (previousSegment?.ability === ability) {
|
||||
previousSegment.count++;
|
||||
} else {
|
||||
segments.push({ ability, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
335
app/features/img-export/components/Graphic.module.css
Normal file
335
app/features/img-export/components/Graphic.module.css
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
.container {
|
||||
--graphic-row-bg: var(--color-bg-high);
|
||||
--graphic-row-border: color-mix(in oklch, var(--color-text) 8%, transparent);
|
||||
--graphic-text-dim: var(--color-text-high);
|
||||
--graphic-accent: var(--color-text-accent);
|
||||
--graphic-first: oklch(87% 0.13 85);
|
||||
--graphic-second: oklch(83% 0.015 268);
|
||||
--graphic-third: oklch(74% 0.09 55);
|
||||
--graphic-win: var(--color-success-high);
|
||||
--graphic-loss: var(--color-error-high);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-4);
|
||||
width: 720px;
|
||||
padding: var(--s-6);
|
||||
background:
|
||||
radial-gradient(
|
||||
ellipse 90% 45% at 50% -5%,
|
||||
color-mix(in oklch, var(--color-accent) 25%, transparent),
|
||||
transparent 70%
|
||||
),
|
||||
var(--color-bg);
|
||||
color: var(--color-text);
|
||||
/* the exported image resolves fonts slightly differently, so any text allowed to wrap eventually does */
|
||||
white-space: nowrap;
|
||||
border: 1.5px solid var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
/*
|
||||
Zero-specificity theme scoping so that a forced [data-theme] wrapper always
|
||||
beats the html class regardless of which one is in effect
|
||||
*/
|
||||
:global(:where(html.light)) .container,
|
||||
:global(:where([data-theme="light"])) .container {
|
||||
--graphic-first: oklch(58% 0.12 85);
|
||||
--graphic-second: oklch(48% 0.02 268);
|
||||
--graphic-third: oklch(50% 0.1 55);
|
||||
}
|
||||
|
||||
:global(:where([data-theme="dark"])) .container {
|
||||
--graphic-first: oklch(87% 0.13 85);
|
||||
--graphic-second: oklch(83% 0.015 268);
|
||||
--graphic-third: oklch(74% 0.09 55);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
/*
|
||||
The image export freezes each element's width, so a shrink-to-fit box would clip its own
|
||||
text. Both sides of the header grow into the free space instead, which keeps them at the
|
||||
same place as an auto margin would but leaves their text room to breathe.
|
||||
*/
|
||||
.headerText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.headerTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
|
||||
& > :not(.headerTitle) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.headerSubtitle {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.headerTrailing {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.box {
|
||||
padding: var(--s-2) var(--s-3);
|
||||
background-color: var(--graphic-row-bg);
|
||||
border: 1.5px solid var(--graphic-row-border);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.boxLabel {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.teamsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.teamRow {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem 2.75rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
padding: var(--s-2) var(--s-3);
|
||||
background-color: var(--graphic-row-bg);
|
||||
border: 1.5px solid var(--graphic-row-border);
|
||||
border-radius: var(--radius-box);
|
||||
|
||||
&.teamRowFirst {
|
||||
background:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in oklch, var(--graphic-first) 18%, transparent),
|
||||
transparent 40%
|
||||
),
|
||||
var(--graphic-row-bg);
|
||||
border-color: color-mix(in oklch, var(--graphic-first) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.placement {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
color: var(--graphic-text-dim);
|
||||
|
||||
& sup {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
}
|
||||
|
||||
.placementFirst {
|
||||
color: var(--graphic-first);
|
||||
}
|
||||
|
||||
.placementSecond {
|
||||
color: var(--graphic-second);
|
||||
}
|
||||
|
||||
.placementThird {
|
||||
color: var(--graphic-third);
|
||||
}
|
||||
|
||||
.teamInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamName {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.avatarCell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.teamSeed {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
white-space: nowrap;
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.playersList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--s-0-5) var(--s-3);
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
|
||||
.player {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
min-width: 0;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.playerName {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.weapons {
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
padding: var(--s-1-5);
|
||||
background-color: oklch(0% 0 0 / 0.3);
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.weaponKit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.statsRow {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 1fr;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
min-width: 0;
|
||||
padding: var(--s-1-5) var(--s-3);
|
||||
background-color: var(--graphic-row-bg);
|
||||
border: 1.5px solid var(--graphic-row-border);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
/* full width rather than shrink-to-fit so the image export cannot clip the value */
|
||||
width: 100%;
|
||||
font-size: var(--font-lg);
|
||||
text-align: center;
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.statSeparator {
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.statWin {
|
||||
color: var(--graphic-win);
|
||||
}
|
||||
|
||||
.statLoss {
|
||||
color: var(--graphic-loss);
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scoreWin {
|
||||
color: var(--graphic-win);
|
||||
}
|
||||
|
||||
.scoreLoss {
|
||||
color: var(--graphic-loss);
|
||||
}
|
||||
|
||||
.sectionDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-extra);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--graphic-accent);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--graphic-row-border);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-extra);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.footerAccent {
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.qrCodeRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qrCode {
|
||||
padding: var(--s-1-5);
|
||||
background-color: #fff;
|
||||
border-radius: var(--radius-field);
|
||||
|
||||
& svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
283
app/features/img-export/components/Graphic.tsx
Normal file
283
app/features/img-export/components/Graphic.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import clsx from "clsx";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import * as React from "react";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { SpecialWeaponImage, WeaponImage } from "~/components/Image";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { weaponParams } from "~/features/build-analyzer/core/utils";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import styles from "./Graphic.module.css";
|
||||
|
||||
export const GRAPHIC_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
/**
|
||||
* Sizes that keep the code at roughly two pixels per module. The lengths are the byte
|
||||
* capacities of the QR versions used at the default "L" error correction level.
|
||||
*/
|
||||
const QR_CODE_SIZE_BREAKPOINTS = [
|
||||
{ maxUrlLength: 106, size: 56 },
|
||||
{ maxUrlLength: 271, size: 84 },
|
||||
{ maxUrlLength: 523, size: 116 },
|
||||
] as const;
|
||||
|
||||
const QR_CODE_SIZE_MAX = 144;
|
||||
|
||||
/** Full URL the graphic's QR code should link to, provided by `ImageExportDialog` (null = no QR code) */
|
||||
export const GraphicQrCodeContext = React.createContext<string | null>(null);
|
||||
|
||||
export interface GraphicPlayer {
|
||||
name: string;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export interface GraphicTeam {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
seed?: number;
|
||||
players: GraphicPlayer[];
|
||||
weapons: MainWeaponId[];
|
||||
}
|
||||
|
||||
export function GraphicContainer({
|
||||
children,
|
||||
width,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
}) {
|
||||
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
|
||||
|
||||
return (
|
||||
<div className={styles.container} style={width ? { width } : undefined}>
|
||||
{children}
|
||||
{qrCodeUrl ? (
|
||||
<div className={styles.qrCodeRow}>
|
||||
<div className={styles.qrCode}>
|
||||
<QRCodeSVG value={qrCodeUrl} size={qrCodeSize(qrCodeUrl)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicHeader({
|
||||
avatarUrl,
|
||||
identiconInput,
|
||||
titleRow,
|
||||
subtitle,
|
||||
trailing,
|
||||
alignTrailingWithTitle = false,
|
||||
}: {
|
||||
avatarUrl?: string;
|
||||
identiconInput: string;
|
||||
titleRow: React.ReactNode;
|
||||
subtitle: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** Line the trailing content up with the title instead of centering it against the title and subtitle together */
|
||||
alignTrailingWithTitle?: boolean;
|
||||
}) {
|
||||
const trailingContent = trailing ? (
|
||||
<div className={styles.headerTrailing}>{trailing}</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<Avatar
|
||||
url={avatarUrl}
|
||||
identiconInput={identiconInput}
|
||||
size="sm"
|
||||
alt=""
|
||||
/>
|
||||
<div className={styles.headerText}>
|
||||
<div className={styles.headerTitleRow}>
|
||||
{titleRow}
|
||||
{alignTrailingWithTitle ? trailingContent : null}
|
||||
</div>
|
||||
{subtitle}
|
||||
</div>
|
||||
{alignTrailingWithTitle ? null : trailingContent}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicTeamsList({ children }: { children: React.ReactNode }) {
|
||||
return <ol className={styles.teamsList}>{children}</ol>;
|
||||
}
|
||||
|
||||
export function GraphicTeamRow({
|
||||
team,
|
||||
leading,
|
||||
highlighted = false,
|
||||
className,
|
||||
as: Element = "li",
|
||||
}: {
|
||||
team: GraphicTeam;
|
||||
leading?: React.ReactNode;
|
||||
highlighted?: boolean;
|
||||
className?: string;
|
||||
as?: "li" | "div";
|
||||
}) {
|
||||
return (
|
||||
<Element
|
||||
className={clsx(styles.teamRow, className, {
|
||||
[styles.teamRowFirst]: highlighted,
|
||||
})}
|
||||
>
|
||||
{leading}
|
||||
<div className={styles.avatarCell}>
|
||||
<Avatar
|
||||
url={team.logoUrl}
|
||||
identiconInput={team.name}
|
||||
size="sm"
|
||||
alt=""
|
||||
/>
|
||||
{typeof team.seed === "number" ? (
|
||||
<div className={styles.teamSeed}>#{team.seed}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.teamInfo}>
|
||||
<div className={styles.teamName}>{team.name}</div>
|
||||
<div className={styles.playersList}>
|
||||
{team.players.map((player) => (
|
||||
<GraphicPlayerChip key={player.name} player={player} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.weapons}>
|
||||
{team.weapons.map((weaponSplId, index) => (
|
||||
<div key={`${weaponSplId}-${index}`} className={styles.weaponKit}>
|
||||
<WeaponImage weaponSplId={weaponSplId} variant="badge" size={38} />
|
||||
<SpecialWeaponImage
|
||||
specialWeaponId={
|
||||
weaponParams().weaponKits[weaponSplId].specialWeaponId
|
||||
}
|
||||
size={24}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A chip is sized by its content, and the image export freezes each element's width. Inline
|
||||
* elements are exempt from that, so the chip must stay a `span` or its name gets clipped.
|
||||
*/
|
||||
export function GraphicPlayerChip({ player }: { player: GraphicPlayer }) {
|
||||
return (
|
||||
<span className={styles.player}>
|
||||
{player.countryCode ? (
|
||||
<Flag countryCode={player.countryCode} tiny />
|
||||
) : null}
|
||||
<span className={styles.playerName}>{player.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicPlacementCell({ placement }: { placement: number }) {
|
||||
return (
|
||||
<div className={clsx(styles.placement, placementAccentClass(placement))}>
|
||||
<Placement placement={placement} textOnly />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicStatsRow({ children }: { children: React.ReactNode }) {
|
||||
return <div className={styles.statsRow}>{children}</div>;
|
||||
}
|
||||
|
||||
export function GraphicStat({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.boxLabel}>{label}</div>
|
||||
<div className={styles.statValue}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicWonLost({ won, lost }: { won: number; lost: number }) {
|
||||
return (
|
||||
<>
|
||||
<span className={styles.statWin}>{won}</span>
|
||||
<span className={styles.statSeparator}>-</span>
|
||||
<span className={styles.statLoss}>{lost}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicScore({
|
||||
ownScore,
|
||||
opponentScore,
|
||||
}: {
|
||||
ownScore: number;
|
||||
opponentScore: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.score,
|
||||
ownScore > opponentScore ? styles.scoreWin : styles.scoreLoss,
|
||||
)}
|
||||
>
|
||||
{ownScore}-{opponentScore}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphicSectionDivider({
|
||||
children,
|
||||
as: Element = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
as?: "div" | "li";
|
||||
}) {
|
||||
return <Element className={styles.sectionDivider}>{children}</Element>;
|
||||
}
|
||||
|
||||
export function GraphicFooter({ children }: { children: React.ReactNode }) {
|
||||
return <footer className={styles.footer}>{children}</footer>;
|
||||
}
|
||||
|
||||
export function GraphicSiteUrl({ path }: { path: string }) {
|
||||
return (
|
||||
<div>
|
||||
sendou<span className={styles.footerAccent}>.ink</span>
|
||||
{path}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function qrCodeSize(url: string) {
|
||||
for (const breakpoint of QR_CODE_SIZE_BREAKPOINTS) {
|
||||
if (url.length <= breakpoint.maxUrlLength) {
|
||||
return breakpoint.size;
|
||||
}
|
||||
}
|
||||
return QR_CODE_SIZE_MAX;
|
||||
}
|
||||
|
||||
function placementAccentClass(placement: number) {
|
||||
switch (placement) {
|
||||
case 1:
|
||||
return styles.placementFirst;
|
||||
case 2:
|
||||
return styles.placementSecond;
|
||||
case 3:
|
||||
return styles.placementThird;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/* doubled selector so it wins over the base modal max-width regardless of CSS module order */
|
||||
.dialog.dialog {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
.settings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-3) var(--s-4);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* auto margins center the frame when the settings make the dialog wider than the graphic, but
|
||||
never clip it when the graphic itself is the wider one (unlike flex centering) */
|
||||
.frame {
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
}
|
||||
178
app/features/img-export/components/ImageExportDialog.tsx
Normal file
178
app/features/img-export/components/ImageExportDialog.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { HardDriveDownload } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useMatches } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
SendouChipRadioGroup,
|
||||
} from "~/components/elements/ChipRadio";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { SendouSwitch } from "~/components/elements/Switch";
|
||||
import { useTheme } from "~/features/theme/core/provider";
|
||||
import { SENDOU_INK_BASE_URL } from "~/utils/urls";
|
||||
import { GraphicQrCodeContext } from "./Graphic";
|
||||
import styles from "./ImageExportDialog.module.css";
|
||||
|
||||
const EXPORT_SCALE = 1.75;
|
||||
|
||||
type ThemeSelection = "light" | "dark" | "light-custom" | "dark-custom";
|
||||
|
||||
const THEME_SELECTIONS = [
|
||||
{
|
||||
value: "light",
|
||||
translationKey: "common:imageExport.theme.light",
|
||||
needsCustomTheme: false,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
translationKey: "common:imageExport.theme.dark",
|
||||
needsCustomTheme: false,
|
||||
},
|
||||
{
|
||||
value: "light-custom",
|
||||
translationKey: "common:imageExport.theme.lightCustom",
|
||||
needsCustomTheme: true,
|
||||
},
|
||||
{
|
||||
value: "dark-custom",
|
||||
translationKey: "common:imageExport.theme.darkCustom",
|
||||
needsCustomTheme: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface ImageExportDialogProps {
|
||||
/** Button that opens the dialog, e.g. a `SendouButton` (its own `onPress` also runs, useful for lazy loading the graphic's data) */
|
||||
trigger: React.ReactNode;
|
||||
heading: string;
|
||||
/** Name of the downloaded file without the extension */
|
||||
filename: string;
|
||||
/** Path the QR code links to, defaults to the current page */
|
||||
qrCodePath?: string;
|
||||
/** Extra settings controls specific to the use case */
|
||||
settings?: React.ReactNode;
|
||||
/** The graphic to preview and export */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for exporting a graphic component as a .png image. Renders the given graphic
|
||||
* as a preview with generic settings (color scheme, custom theme, QR code) and downloads
|
||||
* a screenshot of it via snapdom. Graphics render their QR code via {@link GraphicQrCodeContext}.
|
||||
*/
|
||||
export function ImageExportDialog({
|
||||
trigger,
|
||||
heading,
|
||||
...contentProps
|
||||
}: ImageExportDialogProps) {
|
||||
return (
|
||||
<SendouDialog
|
||||
trigger={trigger}
|
||||
heading={heading}
|
||||
showCloseButton
|
||||
className={styles.dialog}
|
||||
>
|
||||
<ImageExportDialogContent {...contentProps} />
|
||||
</SendouDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageExportDialogContent({
|
||||
filename,
|
||||
qrCodePath,
|
||||
settings,
|
||||
children,
|
||||
}: Omit<ImageExportDialogProps, "trigger" | "heading">) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { htmlThemeClass } = useTheme();
|
||||
const location = useLocation();
|
||||
const pageHasCustomTheme = usePageHasCustomTheme();
|
||||
const [themeSelection, setThemeSelection] = React.useState<ThemeSelection>(
|
||||
() => {
|
||||
const mode = htmlThemeClass === "light" ? "light" : "dark";
|
||||
|
||||
return pageHasCustomTheme ? `${mode}-custom` : mode;
|
||||
},
|
||||
);
|
||||
const [withQrCode, setWithQrCode] = React.useState(true);
|
||||
const frameRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const theme = themeSelection.startsWith("light") ? "light" : "dark";
|
||||
const useCustomTheme = themeSelection.endsWith("-custom");
|
||||
|
||||
const qrCodeUrl = `${SENDOU_INK_BASE_URL}${qrCodePath ?? `${location.pathname}${location.search}`}`;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!frameRef.current) return;
|
||||
|
||||
const { snapdom } = await import("@zumer/snapdom");
|
||||
|
||||
await snapdom.download(frameRef.current, {
|
||||
type: "png",
|
||||
filename,
|
||||
quality: 1,
|
||||
scale: EXPORT_SCALE,
|
||||
embedFonts: true,
|
||||
// without this snapdom re-encodes images down to their rendered size, making e.g. the tier image look rough
|
||||
compress: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className={styles.settings}>
|
||||
<SendouChipRadioGroup wrap>
|
||||
{THEME_SELECTIONS.filter(
|
||||
(selection) => pageHasCustomTheme || !selection.needsCustomTheme,
|
||||
).map((selection) => (
|
||||
<SendouChipRadio
|
||||
key={selection.value}
|
||||
name="image-export-theme"
|
||||
value={selection.value}
|
||||
checked={themeSelection === selection.value}
|
||||
onChange={() => setThemeSelection(selection.value)}
|
||||
>
|
||||
{t(selection.translationKey)}
|
||||
</SendouChipRadio>
|
||||
))}
|
||||
</SendouChipRadioGroup>
|
||||
<SendouSwitch isSelected={withQrCode} onChange={setWithQrCode}>
|
||||
{t("common:imageExport.qrCode")}
|
||||
</SendouSwitch>
|
||||
{settings}
|
||||
</div>
|
||||
<SendouButton
|
||||
icon={<HardDriveDownload />}
|
||||
onPress={handleDownload}
|
||||
className="mx-auto"
|
||||
>
|
||||
{t("common:imageExport.download")}
|
||||
</SendouButton>
|
||||
<div className={styles.scroller}>
|
||||
<div
|
||||
ref={frameRef}
|
||||
className={styles.frame}
|
||||
data-theme={theme}
|
||||
data-default-theme={
|
||||
pageHasCustomTheme && !useCustomTheme ? true : undefined
|
||||
}
|
||||
data-testid="image-export-frame"
|
||||
>
|
||||
<GraphicQrCodeContext.Provider value={withQrCode ? qrCodeUrl : null}>
|
||||
{children}
|
||||
</GraphicQrCodeContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function usePageHasCustomTheme() {
|
||||
const matches = useMatches();
|
||||
|
||||
return matches.some((match) =>
|
||||
Boolean(
|
||||
(match.loaderData as { customTheme?: unknown } | undefined)?.customTheme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
.seasonBadge {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-4);
|
||||
background:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
|
||||
transparent 40%
|
||||
),
|
||||
var(--graphic-row-bg);
|
||||
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.heroTierName {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.heroSp {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.heroPeak {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.rankBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
margin-inline-start: auto;
|
||||
padding-inline-end: var(--s-2);
|
||||
}
|
||||
|
||||
.rankValue {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.bestStageRow {
|
||||
background-image:
|
||||
linear-gradient(to right, var(--graphic-row-bg) 35%, transparent 80%),
|
||||
var(--best-stage-banner);
|
||||
background-origin: border-box;
|
||||
background-position: right center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.bestStageName {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
|
||||
.bestStageWinrate {
|
||||
font-weight: var(--weight-extra);
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.chart {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* the area gradient's stops resolve their color from here via currentColor */
|
||||
color: var(--graphic-accent);
|
||||
}
|
||||
|
||||
.chartLine {
|
||||
fill: none;
|
||||
stroke: var(--graphic-accent);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.chartAreaTop {
|
||||
stop-color: var(--graphic-accent);
|
||||
stop-opacity: 0.3;
|
||||
}
|
||||
|
||||
.chartAreaBottom {
|
||||
stop-color: var(--graphic-accent);
|
||||
stop-opacity: 0;
|
||||
}
|
||||
|
||||
.chartGridLine {
|
||||
stroke: var(--graphic-row-border);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chartLabel {
|
||||
font-size: 10px;
|
||||
font-weight: var(--weight-semi);
|
||||
fill: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.chartPeakLabel {
|
||||
font-size: 11px;
|
||||
font-weight: var(--weight-extra);
|
||||
fill: var(--color-text);
|
||||
}
|
||||
|
||||
.chartDot {
|
||||
fill: var(--graphic-accent);
|
||||
stroke: var(--graphic-row-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.middleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.sideStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.activityBox {
|
||||
/* categorical palette, all-pairs CVD separation validated against the dark surface */
|
||||
--activity-none: color-mix(in oklch, var(--color-text) 8%, transparent);
|
||||
--activity-sq: oklch(70% 0.11 253);
|
||||
--activity-tournament: oklch(75% 0.12 85);
|
||||
--activity-both: oklch(94% 0.02 253);
|
||||
}
|
||||
|
||||
:global(:where(html.light)) .activityBox,
|
||||
:global(:where([data-theme="light"])) .activityBox {
|
||||
--activity-sq: oklch(56% 0.13 253);
|
||||
--activity-tournament: oklch(66% 0.13 85);
|
||||
--activity-both: oklch(30% 0.04 253);
|
||||
}
|
||||
|
||||
:global(:where([data-theme="dark"])) .activityBox {
|
||||
--activity-sq: oklch(70% 0.11 253);
|
||||
--activity-tournament: oklch(75% 0.12 85);
|
||||
--activity-both: oklch(94% 0.02 253);
|
||||
}
|
||||
|
||||
.calendar {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(7, auto);
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: var(--s-4);
|
||||
}
|
||||
|
||||
.calendarCell {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--activity-none);
|
||||
|
||||
&.calendarCellHidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.calendarSq {
|
||||
background-color: var(--activity-sq);
|
||||
}
|
||||
|
||||
&.calendarTournament {
|
||||
background-color: var(--activity-tournament);
|
||||
}
|
||||
|
||||
&.calendarBoth {
|
||||
background-color: var(--activity-both);
|
||||
}
|
||||
}
|
||||
|
||||
.calendarLegend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-2-5);
|
||||
margin-top: var(--s-3);
|
||||
}
|
||||
|
||||
.calendarLegendItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
|
||||
& .calendarCell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.weaponsRow {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
gap: var(--s-3);
|
||||
margin-top: var(--s-1-5);
|
||||
}
|
||||
|
||||
.weaponUsage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.matesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
margin-top: var(--s-1-5);
|
||||
}
|
||||
|
||||
.matesBoxExpanded {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
& .matesList {
|
||||
flex: 1;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
}
|
||||
|
||||
.mateRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.mateName {
|
||||
display: flex;
|
||||
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
min-width: 0;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
|
||||
.mateNameText {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mateSets {
|
||||
margin-inline-start: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teamRankRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
|
||||
/* the info column takes the free space here, so an auto margin would leave it nothing to grow into */
|
||||
& .rankBlock {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.teamRankInfo {
|
||||
/* the image export freezes each element's width, so a shrink-to-fit box would clip its own text */
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.teamRankTitle {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.teamRankName {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.teamRankSp {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.teamRankSpSecondary {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
}
|
||||
|
||||
.playersInline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--s-0-5) var(--s-3);
|
||||
}
|
||||
|
||||
.bestSetsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bestSetRow {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.playersInlineStart {
|
||||
justify-content: flex-start;
|
||||
margin-top: var(--s-1);
|
||||
}
|
||||
|
||||
.setInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setContext {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.setSp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
padding: 0 var(--s-2);
|
||||
}
|
||||
|
||||
.setSpValue {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-extra);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tournamentRow {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem 2.75rem 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.tournamentInfo {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tournamentName {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-extra);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tournamentMeta {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
605
app/features/img-export/components/SeasonSummaryGraphic.tsx
Normal file
605
app/features/img-export/components/SeasonSummaryGraphic.tsx
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
import clsx from "clsx";
|
||||
import {
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
parseISO,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
} from "date-fns";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { TierImage, WeaponImage } from "~/components/Image";
|
||||
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types";
|
||||
import { stageBannerImageUrl, userSeasonsPage } from "~/utils/urls";
|
||||
import {
|
||||
GRAPHIC_DATE_FORMAT_OPTIONS,
|
||||
GraphicContainer,
|
||||
GraphicFooter,
|
||||
GraphicHeader,
|
||||
GraphicPlacementCell,
|
||||
type GraphicPlayer,
|
||||
GraphicPlayerChip,
|
||||
GraphicQrCodeContext,
|
||||
GraphicScore,
|
||||
GraphicSectionDivider,
|
||||
GraphicSiteUrl,
|
||||
GraphicStat,
|
||||
GraphicStatsRow,
|
||||
GraphicWonLost,
|
||||
} from "./Graphic";
|
||||
import graphicStyles from "./Graphic.module.css";
|
||||
import styles from "./SeasonSummaryGraphic.module.css";
|
||||
|
||||
const CHART_WIDTH = 672;
|
||||
const CHART_HEIGHT = 170;
|
||||
const CHART_MARGIN = { top: 26, right: 14, bottom: 22, left: 14 };
|
||||
const CHART_POINTS_NEEDED = 2;
|
||||
const CHART_PEAK_LABEL_CLAMP = 48;
|
||||
const TOP_MATES_COUNT = 3;
|
||||
/** Without weapons the teammates box is alone next to the activity calendar, so it has room for more */
|
||||
const TOP_MATES_COUNT_WITHOUT_WEAPONS = 6;
|
||||
|
||||
export type SeasonSummaryGraphicActivity = "sq" | "tournament" | "both";
|
||||
|
||||
export interface SeasonSummaryGraphicBestSet {
|
||||
opponentPlayers: GraphicPlayer[];
|
||||
ownScore: number;
|
||||
opponentScore: number;
|
||||
/** Average SP of the opposing players at the time the set was played */
|
||||
opponentSp: number;
|
||||
/** Where the set was played e.g. "SendouQ" or a tournament name */
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface SeasonSummaryGraphicStats {
|
||||
tier: { name: TierName; isPlus: boolean };
|
||||
sp: number;
|
||||
setsWon: number;
|
||||
setsLost: number;
|
||||
mapsWon: number;
|
||||
mapsLost: number;
|
||||
longestWinStreak: number;
|
||||
/** Sets that went to a deciding map: how many of those were won */
|
||||
clutch?: { won: number; total: number };
|
||||
soloRank?: number;
|
||||
teamRank?: {
|
||||
/** Omitted when the roster is not on the main team leaderboard */
|
||||
rank?: number;
|
||||
sp: number;
|
||||
mates: GraphicPlayer[];
|
||||
team?: {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
};
|
||||
};
|
||||
topMates: Array<{
|
||||
player: GraphicPlayer;
|
||||
discordId: string;
|
||||
avatarUrl?: string;
|
||||
setsCount: number;
|
||||
}>;
|
||||
bestStage?: { stageId: StageId; winratePercentage: number };
|
||||
/** Per day peak SP, dates in "yyyy-MM-dd" format */
|
||||
spProgression: Array<{ date: string; sp: number }>;
|
||||
/** Days with at least one set played, dates in "yyyy-MM-dd" format */
|
||||
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
|
||||
bestSets: SeasonSummaryGraphicBestSet[];
|
||||
bestTournament?: {
|
||||
name: string;
|
||||
logoUrl?: string;
|
||||
tier?: number;
|
||||
placement: number;
|
||||
teamsCount: number;
|
||||
};
|
||||
topWeapons: Array<{ weaponSplId: MainWeaponId; usagePercentage: number }>;
|
||||
}
|
||||
|
||||
export function SeasonSummaryGraphic({
|
||||
user,
|
||||
season,
|
||||
seasonDateRange,
|
||||
stats,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
discordId: string;
|
||||
customUrl?: string;
|
||||
countryCode?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
season: number;
|
||||
seasonDateRange: { starts: Date; ends: Date };
|
||||
stats: SeasonSummaryGraphicStats;
|
||||
}) {
|
||||
const { t } = useTranslation(["user", "calendar", "game-misc"]);
|
||||
const qrCodeUrl = React.useContext(GraphicQrCodeContext);
|
||||
|
||||
const {
|
||||
tier,
|
||||
sp,
|
||||
setsWon,
|
||||
setsLost,
|
||||
mapsWon,
|
||||
mapsLost,
|
||||
longestWinStreak,
|
||||
clutch,
|
||||
soloRank,
|
||||
teamRank,
|
||||
topMates,
|
||||
bestStage,
|
||||
spProgression,
|
||||
activeDays,
|
||||
bestSets,
|
||||
bestTournament,
|
||||
topWeapons,
|
||||
} = stats;
|
||||
|
||||
const peakSp =
|
||||
spProgression.length > 0
|
||||
? Math.max(...spProgression.map((point) => point.sp))
|
||||
: sp;
|
||||
const shownMates = topMates.slice(
|
||||
0,
|
||||
topWeapons.length > 0 ? TOP_MATES_COUNT : TOP_MATES_COUNT_WITHOUT_WEAPONS,
|
||||
);
|
||||
|
||||
return (
|
||||
<GraphicContainer>
|
||||
<GraphicHeader
|
||||
avatarUrl={user.avatarUrl}
|
||||
identiconInput={user.discordId}
|
||||
titleRow={
|
||||
<>
|
||||
{user.countryCode ? (
|
||||
<Flag countryCode={user.countryCode} tiny />
|
||||
) : null}
|
||||
<span className={graphicStyles.headerTitle}>{user.name}</span>
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<LocaleTimeRange
|
||||
from={seasonDateRange.starts}
|
||||
to={seasonDateRange.ends}
|
||||
options={GRAPHIC_DATE_FORMAT_OPTIONS}
|
||||
className={graphicStyles.headerSubtitle}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<div className={styles.seasonBadge}>
|
||||
{t("user:seasons.season")} {season}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className={clsx(graphicStyles.box, styles.hero)}>
|
||||
<TierImage tier={tier} width={92} />
|
||||
<div>
|
||||
<div className={styles.heroTierName}>
|
||||
{tier.name}
|
||||
{tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
<div className={styles.heroSp}>{sp.toFixed(1)}SP</div>
|
||||
<div className={styles.heroPeak}>
|
||||
{t("user:seasons.peak")} {peakSp.toFixed(1)}SP
|
||||
</div>
|
||||
</div>
|
||||
{typeof soloRank === "number" ? (
|
||||
<div className={styles.rankBlock}>
|
||||
<div className={styles.rankValue}>#{soloRank}</div>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.soloRank")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{teamRank ? (
|
||||
<div className={clsx(graphicStyles.box, styles.teamRankRow)}>
|
||||
{teamRank.team ? (
|
||||
<Avatar
|
||||
url={teamRank.team.logoUrl}
|
||||
identiconInput={teamRank.team.name}
|
||||
size="sm"
|
||||
alt=""
|
||||
/>
|
||||
) : null}
|
||||
<div className={styles.teamRankInfo}>
|
||||
<div className={styles.teamRankTitle}>
|
||||
{teamRank.team ? (
|
||||
<span className={styles.teamRankName}>
|
||||
{teamRank.team.name}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(styles.teamRankSp, {
|
||||
[styles.teamRankSpSecondary]: Boolean(teamRank.team),
|
||||
})}
|
||||
>
|
||||
{teamRank.sp.toFixed(1)}SP
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.playersInline, styles.playersInlineStart)}
|
||||
>
|
||||
{teamRank.mates.map((mate) => (
|
||||
<GraphicPlayerChip key={mate.name} player={mate} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{typeof teamRank.rank === "number" ? (
|
||||
<div className={styles.rankBlock}>
|
||||
<div className={styles.rankValue}>#{teamRank.rank}</div>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.teamRank")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<GraphicStatsRow>
|
||||
<GraphicStat label={t("user:seasons.summary.sets")}>
|
||||
<GraphicWonLost won={setsWon} lost={setsLost} />
|
||||
</GraphicStat>
|
||||
<GraphicStat label={t("user:seasons.summary.maps")}>
|
||||
<GraphicWonLost won={mapsWon} lost={mapsLost} />
|
||||
</GraphicStat>
|
||||
{clutch && clutch.total > 0 ? (
|
||||
<GraphicStat label={t("user:seasons.summary.clutch")}>
|
||||
<GraphicWonLost won={clutch.won} lost={clutch.total - clutch.won} />
|
||||
</GraphicStat>
|
||||
) : null}
|
||||
<GraphicStat label={t("user:seasons.summary.winStreak")}>
|
||||
{longestWinStreak}
|
||||
</GraphicStat>
|
||||
</GraphicStatsRow>
|
||||
{spProgression.length >= CHART_POINTS_NEEDED ? (
|
||||
<div className={graphicStyles.box}>
|
||||
<SpChart points={spProgression} />
|
||||
</div>
|
||||
) : null}
|
||||
{bestStage ? (
|
||||
<div
|
||||
className={clsx(graphicStyles.box, styles.bestStageRow)}
|
||||
style={
|
||||
{
|
||||
"--best-stage-banner": `url(${stageBannerImageUrl(bestStage.stageId)})`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.bestStage")}
|
||||
</div>
|
||||
<div className={styles.bestStageName}>
|
||||
{t(`game-misc:STAGE_${bestStage.stageId}`)}{" "}
|
||||
<span className={styles.bestStageWinrate}>
|
||||
{Math.round(bestStage.winratePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.middleGrid}>
|
||||
<div className={clsx(graphicStyles.box, styles.activityBox)}>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.activity")}
|
||||
</div>
|
||||
<ActivityCalendar
|
||||
seasonDateRange={seasonDateRange}
|
||||
activeDays={activeDays}
|
||||
/>
|
||||
<ActivityLegend />
|
||||
</div>
|
||||
<div className={styles.sideStack}>
|
||||
{topWeapons.length > 0 ? (
|
||||
<div className={graphicStyles.box}>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.topWeapons")}
|
||||
</div>
|
||||
<div className={styles.weaponsRow}>
|
||||
{topWeapons.map((weapon) => (
|
||||
<div key={weapon.weaponSplId} className={styles.weaponUsage}>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon.weaponSplId}
|
||||
variant="badge"
|
||||
size={52}
|
||||
/>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{Math.round(weapon.usagePercentage)}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{shownMates.length > 0 ? (
|
||||
<div
|
||||
className={clsx(graphicStyles.box, {
|
||||
[styles.matesBoxExpanded]: topWeapons.length === 0,
|
||||
})}
|
||||
>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.topMates")}
|
||||
</div>
|
||||
<div className={styles.matesList}>
|
||||
{shownMates.map((mate) => (
|
||||
<div key={mate.player.name} className={styles.mateRow}>
|
||||
<Avatar
|
||||
url={mate.avatarUrl}
|
||||
identiconInput={mate.discordId}
|
||||
size="xxs"
|
||||
alt=""
|
||||
/>
|
||||
<div className={styles.mateName}>
|
||||
{mate.player.countryCode ? (
|
||||
<Flag countryCode={mate.player.countryCode} tiny />
|
||||
) : null}
|
||||
<span className={styles.mateNameText}>
|
||||
{mate.player.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(graphicStyles.boxLabel, styles.mateSets)}
|
||||
>
|
||||
{t("user:seasons.summary.count.sets", {
|
||||
count: mate.setsCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{bestSets.length > 0 ? (
|
||||
<>
|
||||
<GraphicSectionDivider>
|
||||
{t("user:seasons.summary.bestWins")}
|
||||
</GraphicSectionDivider>
|
||||
<ol className={styles.bestSetsList}>
|
||||
{bestSets.map((set, index) => (
|
||||
<li
|
||||
key={`${index}-${set.context}`}
|
||||
className={clsx(graphicStyles.box, styles.bestSetRow)}
|
||||
>
|
||||
<GraphicScore
|
||||
ownScore={set.ownScore}
|
||||
opponentScore={set.opponentScore}
|
||||
/>
|
||||
<div className={styles.setInfo}>
|
||||
<div
|
||||
className={clsx(graphicStyles.boxLabel, styles.setContext)}
|
||||
>
|
||||
{set.context}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.playersInline,
|
||||
styles.playersInlineStart,
|
||||
)}
|
||||
>
|
||||
{set.opponentPlayers.map((player) => (
|
||||
<GraphicPlayerChip key={player.name} player={player} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.setSp}>
|
||||
<div className={styles.setSpValue}>
|
||||
{set.opponentSp.toFixed(1)}
|
||||
</div>
|
||||
<div className={graphicStyles.boxLabel}>
|
||||
{t("user:seasons.summary.opponentSp")}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
) : null}
|
||||
{bestTournament ? (
|
||||
<>
|
||||
<GraphicSectionDivider>
|
||||
{t("user:seasons.summary.bestTournament")}
|
||||
</GraphicSectionDivider>
|
||||
<div className={clsx(graphicStyles.box, styles.tournamentRow)}>
|
||||
<GraphicPlacementCell placement={bestTournament.placement} />
|
||||
<Avatar
|
||||
url={bestTournament.logoUrl}
|
||||
identiconInput={bestTournament.name}
|
||||
size="sm"
|
||||
alt=""
|
||||
/>
|
||||
<div className={styles.tournamentInfo}>
|
||||
<div className={styles.tournamentName}>{bestTournament.name}</div>
|
||||
<div className={styles.tournamentMeta}>
|
||||
{t("calendar:count.teams", {
|
||||
count: bestTournament.teamsCount,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{typeof bestTournament.tier === "number" ? (
|
||||
<TierPill tier={bestTournament.tier} />
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{qrCodeUrl ? null : (
|
||||
<GraphicFooter>
|
||||
<div>
|
||||
{t("user:seasons.summary.count.sets", {
|
||||
count: setsWon + setsLost,
|
||||
})}{" "}
|
||||
·{" "}
|
||||
{t("user:seasons.summary.count.maps", {
|
||||
count: mapsWon + mapsLost,
|
||||
})}
|
||||
</div>
|
||||
<GraphicSiteUrl path={userSeasonsPage({ user, season })} />
|
||||
</GraphicFooter>
|
||||
)}
|
||||
</GraphicContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SpChart({ points }: { points: Array<{ date: string; sp: number }> }) {
|
||||
const { formatter } = useDateTimeFormat({ month: "short", day: "numeric" });
|
||||
const gradientId = React.useId();
|
||||
|
||||
const times = points.map((point) => parseISO(point.date).getTime());
|
||||
const minTime = times[0];
|
||||
const maxTime = times[times.length - 1];
|
||||
const sps = points.map((point) => point.sp);
|
||||
const minSp = Math.min(...sps);
|
||||
const maxSp = Math.max(...sps);
|
||||
|
||||
const innerWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right;
|
||||
const innerHeight = CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom;
|
||||
const bottomY = CHART_HEIGHT - CHART_MARGIN.bottom;
|
||||
|
||||
const xAt = (time: number) =>
|
||||
CHART_MARGIN.left +
|
||||
((time - minTime) / Math.max(maxTime - minTime, 1)) * innerWidth;
|
||||
const yAt = (spValue: number) =>
|
||||
CHART_MARGIN.top +
|
||||
(1 - (spValue - minSp) / Math.max(maxSp - minSp, 1)) * innerHeight;
|
||||
|
||||
const linePath = points
|
||||
.map(
|
||||
(point, index) =>
|
||||
`${index === 0 ? "M" : "L"}${xAt(times[index]).toFixed(1)} ${yAt(point.sp).toFixed(1)}`,
|
||||
)
|
||||
.join(" ");
|
||||
const areaPath = `${linePath} L${xAt(maxTime).toFixed(1)} ${bottomY} L${xAt(minTime).toFixed(1)} ${bottomY} Z`;
|
||||
|
||||
const peakIndex = sps.indexOf(maxSp);
|
||||
const peakX = xAt(times[peakIndex]);
|
||||
const peakY = yAt(maxSp);
|
||||
const peakLabelX = Math.min(
|
||||
Math.max(peakX, CHART_PEAK_LABEL_CLAMP),
|
||||
CHART_WIDTH - CHART_PEAK_LABEL_CLAMP,
|
||||
);
|
||||
|
||||
return (
|
||||
<svg
|
||||
className={styles.chart}
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="SP"
|
||||
>
|
||||
<defs>
|
||||
{/* presentation attributes, not CSS: the image export does not style elements inside defs */}
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="currentColor" stopOpacity={0.3} />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line
|
||||
className={styles.chartGridLine}
|
||||
x1={CHART_MARGIN.left}
|
||||
y1={yAt(minSp)}
|
||||
x2={CHART_WIDTH - CHART_MARGIN.right}
|
||||
y2={yAt(minSp)}
|
||||
/>
|
||||
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||
<path className={styles.chartLine} d={linePath} />
|
||||
<circle className={styles.chartDot} cx={peakX} cy={peakY} r={4.5} />
|
||||
<text
|
||||
className={styles.chartPeakLabel}
|
||||
x={peakLabelX}
|
||||
y={peakY - 10}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{maxSp.toFixed(1)}SP
|
||||
</text>
|
||||
<text
|
||||
className={styles.chartLabel}
|
||||
x={CHART_MARGIN.left}
|
||||
y={CHART_HEIGHT - 6}
|
||||
>
|
||||
{formatter.format(parseISO(points[0].date))}
|
||||
</text>
|
||||
<text
|
||||
className={styles.chartLabel}
|
||||
x={CHART_WIDTH - CHART_MARGIN.right}
|
||||
y={CHART_HEIGHT - 6}
|
||||
textAnchor="end"
|
||||
>
|
||||
{formatter.format(parseISO(points[points.length - 1].date))}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityCalendar({
|
||||
seasonDateRange,
|
||||
activeDays,
|
||||
}: {
|
||||
seasonDateRange: { starts: Date; ends: Date };
|
||||
activeDays: Array<{ date: string; activity: SeasonSummaryGraphicActivity }>;
|
||||
}) {
|
||||
const activityByDay = new Map(
|
||||
activeDays.map((day) => [day.date, day.activity]),
|
||||
);
|
||||
const seasonFirstDay = startOfDay(seasonDateRange.starts);
|
||||
const days = eachDayOfInterval({
|
||||
start: startOfWeek(seasonFirstDay, { weekStartsOn: 1 }),
|
||||
end: seasonDateRange.ends,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{days.map((day) => {
|
||||
const key = format(day, "yyyy-MM-dd");
|
||||
const beforeSeason = day.getTime() < seasonFirstDay.getTime();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
styles.calendarCell,
|
||||
activityClass(activityByDay.get(key)),
|
||||
{ [styles.calendarCellHidden]: beforeSeason },
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLegend() {
|
||||
const { t } = useTranslation(["user"]);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.calendarLegend, graphicStyles.boxLabel)}>
|
||||
<div className={styles.calendarLegendItem}>
|
||||
<div className={clsx(styles.calendarCell, styles.calendarSq)} />
|
||||
SendouQ
|
||||
</div>
|
||||
<div className={styles.calendarLegendItem}>
|
||||
<div className={clsx(styles.calendarCell, styles.calendarTournament)} />
|
||||
{t("user:seasons.summary.activity.tournament")}
|
||||
</div>
|
||||
<div className={styles.calendarLegendItem}>
|
||||
<div className={clsx(styles.calendarCell, styles.calendarBoth)} />
|
||||
{t("user:seasons.summary.activity.both")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function activityClass(activity?: SeasonSummaryGraphicActivity) {
|
||||
if (!activity) return undefined;
|
||||
|
||||
switch (activity) {
|
||||
case "sq":
|
||||
return styles.calendarSq;
|
||||
case "tournament":
|
||||
return styles.calendarTournament;
|
||||
case "both":
|
||||
return styles.calendarBoth;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
.organizationName {
|
||||
max-width: 12rem;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
140
app/features/img-export/components/TournamentResultsGraphic.tsx
Normal file
140
app/features/img-export/components/TournamentResultsGraphic.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { TierPill } from "~/components/TierPill";
|
||||
import { tournamentPage } from "~/utils/urls";
|
||||
import {
|
||||
GRAPHIC_DATE_FORMAT_OPTIONS,
|
||||
GraphicContainer,
|
||||
GraphicFooter,
|
||||
GraphicHeader,
|
||||
GraphicPlacementCell,
|
||||
GraphicSiteUrl,
|
||||
type GraphicTeam,
|
||||
GraphicTeamRow,
|
||||
GraphicTeamsList,
|
||||
} from "./Graphic";
|
||||
import graphicStyles from "./Graphic.module.css";
|
||||
import styles from "./TournamentResultsGraphic.module.css";
|
||||
|
||||
export interface TournamentResultsGraphicTeam extends GraphicTeam {
|
||||
placement: number;
|
||||
}
|
||||
|
||||
export function TournamentResultsGraphic({
|
||||
tournamentId,
|
||||
tournamentName,
|
||||
startTime,
|
||||
logoUrl,
|
||||
tier,
|
||||
organization,
|
||||
teams,
|
||||
teamsCount,
|
||||
playersCount,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
tournamentName: string;
|
||||
startTime: Date;
|
||||
logoUrl?: string;
|
||||
tier?: number;
|
||||
organization?: { name: string; avatarUrl?: string };
|
||||
teams: TournamentResultsGraphicTeam[];
|
||||
teamsCount: number;
|
||||
playersCount: number;
|
||||
}) {
|
||||
return (
|
||||
<GraphicContainer>
|
||||
<TournamentGraphicHeader
|
||||
tournamentName={tournamentName}
|
||||
startTime={startTime}
|
||||
logoUrl={logoUrl}
|
||||
tier={tier}
|
||||
organization={organization}
|
||||
/>
|
||||
<GraphicTeamsList>
|
||||
{teams.map((team) => (
|
||||
<GraphicTeamRow
|
||||
key={`${team.placement}-${team.name}`}
|
||||
team={team}
|
||||
highlighted={team.placement === 1}
|
||||
leading={<GraphicPlacementCell placement={team.placement} />}
|
||||
/>
|
||||
))}
|
||||
</GraphicTeamsList>
|
||||
<TournamentGraphicFooter
|
||||
teamsCount={teamsCount}
|
||||
playersCount={playersCount}
|
||||
path={tournamentPage(tournamentId)}
|
||||
/>
|
||||
</GraphicContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function TournamentGraphicHeader({
|
||||
tournamentName,
|
||||
startTime,
|
||||
logoUrl,
|
||||
tier,
|
||||
organization,
|
||||
}: {
|
||||
tournamentName: string;
|
||||
startTime: Date;
|
||||
logoUrl?: string;
|
||||
tier?: number;
|
||||
organization?: { name: string; avatarUrl?: string };
|
||||
}) {
|
||||
return (
|
||||
<GraphicHeader
|
||||
avatarUrl={logoUrl}
|
||||
identiconInput={tournamentName}
|
||||
titleRow={
|
||||
<>
|
||||
<span className={graphicStyles.headerTitle}>{tournamentName}</span>
|
||||
{typeof tier === "number" ? <TierPill tier={tier} /> : null}
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<LocaleTime
|
||||
date={startTime}
|
||||
options={GRAPHIC_DATE_FORMAT_OPTIONS}
|
||||
className={graphicStyles.headerSubtitle}
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
organization ? (
|
||||
<>
|
||||
<span className={styles.organizationName}>{organization.name}</span>
|
||||
<Avatar
|
||||
url={organization.avatarUrl}
|
||||
identiconInput={organization.name}
|
||||
size="xs"
|
||||
alt=""
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TournamentGraphicFooter({
|
||||
teamsCount,
|
||||
playersCount,
|
||||
path,
|
||||
}: {
|
||||
teamsCount: number;
|
||||
playersCount: number;
|
||||
path: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
|
||||
return (
|
||||
<GraphicFooter>
|
||||
<div>
|
||||
{t("calendar:count.teams", { count: teamsCount })} ·{" "}
|
||||
{t("calendar:count.players", { count: playersCount })}
|
||||
</div>
|
||||
<GraphicSiteUrl path={path} />
|
||||
</GraphicFooter>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
.ownTeamRow {
|
||||
grid-template-columns: 2.75rem 1fr auto;
|
||||
background:
|
||||
linear-gradient(
|
||||
to right,
|
||||
color-mix(in oklch, var(--graphic-accent) 16%, transparent),
|
||||
transparent 40%
|
||||
),
|
||||
var(--graphic-row-bg);
|
||||
border-color: color-mix(in oklch, var(--graphic-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.matchRow {
|
||||
grid-template-columns: 5.5rem 2.75rem 1fr auto;
|
||||
}
|
||||
|
||||
.matchLeading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
}
|
||||
|
||||
.roundName {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seriesWin {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.seriesWinName {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-sm);
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.seriesWinDate {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.seriesTitlesCount {
|
||||
color: var(--graphic-first);
|
||||
}
|
||||
197
app/features/img-export/components/TournamentRunGraphic.tsx
Normal file
197
app/features/img-export/components/TournamentRunGraphic.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import clsx from "clsx";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as R from "remeda";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { tournamentTeamPage } from "~/utils/urls";
|
||||
import {
|
||||
GraphicContainer,
|
||||
GraphicPlacementCell,
|
||||
GraphicScore,
|
||||
GraphicSectionDivider,
|
||||
GraphicStat,
|
||||
GraphicStatsRow,
|
||||
type GraphicTeam,
|
||||
GraphicTeamRow,
|
||||
GraphicTeamsList,
|
||||
GraphicWonLost,
|
||||
} from "./Graphic";
|
||||
import graphicStyles from "./Graphic.module.css";
|
||||
import {
|
||||
TournamentGraphicFooter,
|
||||
TournamentGraphicHeader,
|
||||
type TournamentResultsGraphicTeam,
|
||||
} from "./TournamentResultsGraphic";
|
||||
import styles from "./TournamentRunGraphic.module.css";
|
||||
|
||||
const SERIES_WIN_DATE_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
};
|
||||
|
||||
export interface TournamentRunGraphicSeriesWin {
|
||||
name: string;
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export interface TournamentRunGraphicMatch {
|
||||
opponent: GraphicTeam;
|
||||
ownScore: number;
|
||||
opponentScore: number;
|
||||
roundName: string;
|
||||
bracketName: string;
|
||||
}
|
||||
|
||||
export function TournamentRunGraphic({
|
||||
tournamentId,
|
||||
tournamentTeamId,
|
||||
tournamentName,
|
||||
startTime,
|
||||
logoUrl,
|
||||
tier,
|
||||
organization,
|
||||
team,
|
||||
seed,
|
||||
matches,
|
||||
teamsCount,
|
||||
playersCount,
|
||||
seriesWins,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
tournamentTeamId: number;
|
||||
tournamentName: string;
|
||||
startTime: Date;
|
||||
logoUrl?: string;
|
||||
tier?: number;
|
||||
organization?: { name: string; avatarUrl?: string };
|
||||
team: TournamentResultsGraphicTeam;
|
||||
seed?: number;
|
||||
matches: TournamentRunGraphicMatch[];
|
||||
teamsCount: number;
|
||||
playersCount: number;
|
||||
seriesWins?: {
|
||||
totalCount: number;
|
||||
first: TournamentRunGraphicSeriesWin;
|
||||
latest?: TournamentRunGraphicSeriesWin;
|
||||
};
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
|
||||
const setsWon = matches.filter(
|
||||
(match) => match.ownScore > match.opponentScore,
|
||||
).length;
|
||||
const setsLost = matches.length - setsWon;
|
||||
const mapsWon = R.sumBy(matches, (match) => match.ownScore);
|
||||
const mapsLost = R.sumBy(matches, (match) => match.opponentScore);
|
||||
|
||||
return (
|
||||
<GraphicContainer>
|
||||
<TournamentGraphicHeader
|
||||
tournamentName={tournamentName}
|
||||
startTime={startTime}
|
||||
logoUrl={logoUrl}
|
||||
tier={tier}
|
||||
organization={organization}
|
||||
/>
|
||||
<GraphicTeamRow as="div" team={team} className={styles.ownTeamRow} />
|
||||
<GraphicStatsRow>
|
||||
<GraphicStat label={t("tournament:team.placement")}>
|
||||
<GraphicPlacementCell placement={team.placement} />
|
||||
</GraphicStat>
|
||||
{typeof seed === "number" ? (
|
||||
<GraphicStat label={t("tournament:team.seed")}>{seed}</GraphicStat>
|
||||
) : null}
|
||||
<GraphicStat label={t("tournament:run.sets")}>
|
||||
<GraphicWonLost won={setsWon} lost={setsLost} />
|
||||
</GraphicStat>
|
||||
<GraphicStat label={t("tournament:run.maps")}>
|
||||
<GraphicWonLost won={mapsWon} lost={mapsLost} />
|
||||
</GraphicStat>
|
||||
</GraphicStatsRow>
|
||||
{team.placement === 1 && seriesWins ? (
|
||||
<GraphicStatsRow>
|
||||
<SeriesWinStat
|
||||
label={t("tournament:run.firstTitle")}
|
||||
win={seriesWins.first}
|
||||
/>
|
||||
{seriesWins.latest ? (
|
||||
<SeriesWinStat
|
||||
label={t("tournament:run.latestTitle")}
|
||||
win={seriesWins.latest}
|
||||
/>
|
||||
) : null}
|
||||
<GraphicStat label={t("tournament:run.seriesTitles")}>
|
||||
<span className={styles.seriesTitlesCount}>
|
||||
{seriesWins.totalCount}
|
||||
</span>
|
||||
</GraphicStat>
|
||||
</GraphicStatsRow>
|
||||
) : null}
|
||||
<GraphicTeamsList>
|
||||
{matches.map((match, index) => {
|
||||
const previousMatch = matches[index - 1];
|
||||
const qualifiedForBracket =
|
||||
previousMatch && previousMatch.bracketName !== match.bracketName;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${index}-${match.opponent.name}`}>
|
||||
{qualifiedForBracket ? (
|
||||
<GraphicSectionDivider as="li">
|
||||
<ArrowDown size={14} />
|
||||
{t("tournament:run.qualifiedFor", {
|
||||
bracket: match.bracketName,
|
||||
})}
|
||||
</GraphicSectionDivider>
|
||||
) : null}
|
||||
<GraphicTeamRow
|
||||
className={styles.matchRow}
|
||||
team={match.opponent}
|
||||
leading={
|
||||
<div className={styles.matchLeading}>
|
||||
<div
|
||||
className={clsx(graphicStyles.boxLabel, styles.roundName)}
|
||||
>
|
||||
{match.roundName}
|
||||
</div>
|
||||
<GraphicScore
|
||||
ownScore={match.ownScore}
|
||||
opponentScore={match.opponentScore}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</GraphicTeamsList>
|
||||
<TournamentGraphicFooter
|
||||
teamsCount={teamsCount}
|
||||
playersCount={playersCount}
|
||||
path={tournamentTeamPage({ tournamentId, tournamentTeamId })}
|
||||
/>
|
||||
</GraphicContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesWinStat({
|
||||
label,
|
||||
win,
|
||||
}: {
|
||||
label: string;
|
||||
win: TournamentRunGraphicSeriesWin;
|
||||
}) {
|
||||
return (
|
||||
<GraphicStat label={label}>
|
||||
<div className={styles.seriesWin}>
|
||||
<div className={styles.seriesWinName}>{win.name}</div>
|
||||
<LocaleTime
|
||||
date={win.startTime}
|
||||
options={SERIES_WIN_DATE_FORMAT_OPTIONS}
|
||||
className={styles.seriesWinDate}
|
||||
/>
|
||||
</div>
|
||||
</GraphicStat>
|
||||
);
|
||||
}
|
||||
326
app/features/img-export/core/SeasonSummary.test.ts
Normal file
326
app/features/img-export/core/SeasonSummary.test.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as SeasonSummary from "./SeasonSummary";
|
||||
|
||||
const win = { ownScore: 4, opponentScore: 2 };
|
||||
const loss = { ownScore: 1, opponentScore: 4 };
|
||||
|
||||
describe("longestWinStreak", () => {
|
||||
it("returns 0 for no sets", () => {
|
||||
expect(SeasonSummary.longestWinStreak([])).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 when every set was lost", () => {
|
||||
expect(SeasonSummary.longestWinStreak([loss, loss])).toBe(0);
|
||||
});
|
||||
|
||||
it("counts consecutive wins only", () => {
|
||||
expect(
|
||||
SeasonSummary.longestWinStreak([win, win, loss, win, win, win, loss]),
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it("counts a streak lasting until the end", () => {
|
||||
expect(SeasonSummary.longestWinStreak([loss, win, win])).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clutchRecord", () => {
|
||||
it("returns zeros for no sets", () => {
|
||||
expect(SeasonSummary.clutchRecord([])).toEqual({ won: 0, total: 0 });
|
||||
});
|
||||
|
||||
it("only counts sets decided by one map", () => {
|
||||
expect(
|
||||
SeasonSummary.clutchRecord([
|
||||
{ ownScore: 4, opponentScore: 3 },
|
||||
{ ownScore: 2, opponentScore: 3 },
|
||||
{ ownScore: 4, opponentScore: 2 },
|
||||
{ ownScore: 0, opponentScore: 4 },
|
||||
{ ownScore: 2, opponentScore: 1 },
|
||||
]),
|
||||
).toEqual({ won: 2, total: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestStage", () => {
|
||||
it("returns undefined when no stage has enough maps played", () => {
|
||||
expect(
|
||||
SeasonSummary.bestStage({
|
||||
1: { SZ: { wins: 2, losses: 0 } },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("aggregates winrate across modes", () => {
|
||||
expect(
|
||||
SeasonSummary.bestStage({
|
||||
1: { SZ: { wins: 4, losses: 2 }, TC: { wins: 2, losses: 2 } },
|
||||
}),
|
||||
).toEqual({ stageId: 1, winratePercentage: 60 });
|
||||
});
|
||||
|
||||
it("picks the stage with the highest winrate among qualified ones", () => {
|
||||
expect(
|
||||
SeasonSummary.bestStage({
|
||||
1: { SZ: { wins: 9, losses: 1 } },
|
||||
2: { SZ: { wins: 5, losses: 5 } },
|
||||
3: { SZ: { wins: 8, losses: 2 } },
|
||||
}),
|
||||
).toEqual({ stageId: 1, winratePercentage: 90 });
|
||||
});
|
||||
|
||||
it("does not let a low sample size stage win over a qualified one", () => {
|
||||
expect(
|
||||
SeasonSummary.bestStage({
|
||||
1: { SZ: { wins: 3, losses: 0 } },
|
||||
2: { SZ: { wins: 7, losses: 3 } },
|
||||
}),
|
||||
).toEqual({ stageId: 2, winratePercentage: 70 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("tournamentRunScore", () => {
|
||||
it("lets tier dominate over placement quality", () => {
|
||||
const higherTierRun = SeasonSummary.tournamentRunScore({
|
||||
tier: 2,
|
||||
placement: 2,
|
||||
teamsCount: 32,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
const lowerTierWin = SeasonSummary.tournamentRunScore({
|
||||
tier: 3,
|
||||
placement: 1,
|
||||
teamsCount: 64,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
|
||||
expect(higherTierRun).toBeGreaterThan(lowerTierWin);
|
||||
});
|
||||
|
||||
it("rewards better placement within the same tier", () => {
|
||||
const winner = SeasonSummary.tournamentRunScore({
|
||||
tier: 5,
|
||||
placement: 1,
|
||||
teamsCount: 16,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
const runnerUp = SeasonSummary.tournamentRunScore({
|
||||
tier: 5,
|
||||
placement: 2,
|
||||
teamsCount: 16,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
|
||||
expect(winner).toBeGreaterThan(runnerUp);
|
||||
});
|
||||
|
||||
it("scores an untiered tournament below a tiered one with a similar run", () => {
|
||||
const untiered = SeasonSummary.tournamentRunScore({
|
||||
tier: null,
|
||||
placement: 1,
|
||||
teamsCount: 16,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
const tiered = SeasonSummary.tournamentRunScore({
|
||||
tier: 9,
|
||||
placement: 1,
|
||||
teamsCount: 16,
|
||||
topEightAvgSp: null,
|
||||
});
|
||||
|
||||
expect(untiered).toBeLessThan(tiered);
|
||||
});
|
||||
|
||||
it("breaks a tie between identical runs of the same tier by field strength", () => {
|
||||
const strongField = SeasonSummary.tournamentRunScore({
|
||||
tier: 4,
|
||||
placement: 3,
|
||||
teamsCount: 24,
|
||||
topEightAvgSp: 2600,
|
||||
});
|
||||
const weakField = SeasonSummary.tournamentRunScore({
|
||||
tier: 4,
|
||||
placement: 3,
|
||||
teamsCount: 24,
|
||||
topEightAvgSp: 1400,
|
||||
});
|
||||
|
||||
expect(strongField).toBeGreaterThan(weakField);
|
||||
});
|
||||
|
||||
it("does not let field strength outweigh a tier step", () => {
|
||||
const strongerField = SeasonSummary.tournamentRunScore({
|
||||
tier: 4,
|
||||
placement: 3,
|
||||
teamsCount: 24,
|
||||
topEightAvgSp: 3000,
|
||||
});
|
||||
const higherTier = SeasonSummary.tournamentRunScore({
|
||||
tier: 3,
|
||||
placement: 3,
|
||||
teamsCount: 24,
|
||||
topEightAvgSp: 1200,
|
||||
});
|
||||
|
||||
expect(strongerField).toBeLessThan(higherTier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bestTournamentRun", () => {
|
||||
it("returns undefined for no runs", () => {
|
||||
expect(SeasonSummary.bestTournamentRun([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("picks the run with the highest score", () => {
|
||||
const runs = [
|
||||
{ tier: 6, placement: 1, teamsCount: 32, topEightAvgSp: 2400 },
|
||||
{ tier: 2, placement: 10, teamsCount: 32, topEightAvgSp: 1800 },
|
||||
{ tier: null, placement: 1, teamsCount: 100, topEightAvgSp: 2900 },
|
||||
];
|
||||
|
||||
expect(SeasonSummary.bestTournamentRun(runs)).toBe(runs[1]);
|
||||
});
|
||||
|
||||
it("picks the stronger field among runs tied by tier and placement", () => {
|
||||
const runs = [
|
||||
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: 1900 },
|
||||
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: 2500 },
|
||||
{ tier: 3, placement: 5, teamsCount: 32, topEightAvgSp: null },
|
||||
];
|
||||
|
||||
expect(SeasonSummary.bestTournamentRun(runs)).toBe(runs[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("topWeaponUsages", () => {
|
||||
it("returns empty array for no reported weapons", () => {
|
||||
expect(SeasonSummary.topWeaponUsages([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the most used weapons with their usage share", () => {
|
||||
expect(
|
||||
SeasonSummary.topWeaponUsages([
|
||||
{ weaponSplId: 40, count: 10 },
|
||||
{ weaponSplId: 1001, count: 5 },
|
||||
{ weaponSplId: 2070, count: 4 },
|
||||
{ weaponSplId: 0, count: 1 },
|
||||
]),
|
||||
).toEqual([
|
||||
{ weaponSplId: 40, usagePercentage: 50 },
|
||||
{ weaponSplId: 1001, usagePercentage: 25 },
|
||||
{ weaponSplId: 2070, usagePercentage: 20 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// season 11 ended 2026-05-17, season 12 started 2026-06-01
|
||||
const OFF_SEASON_DATE = new Date("2026-05-20T12:00:00Z");
|
||||
const MID_SEASON_12_DATE = new Date("2026-06-10T12:00:00Z");
|
||||
|
||||
describe("isSeasonExportableByAll", () => {
|
||||
it("latest finished season is exportable during off-season", () => {
|
||||
expect(SeasonSummary.isSeasonExportableByAll(11, OFF_SEASON_DATE)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("older seasons are not exportable during off-season", () => {
|
||||
expect(SeasonSummary.isSeasonExportableByAll(10, OFF_SEASON_DATE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("nothing is exportable while a season is in progress", () => {
|
||||
expect(SeasonSummary.isSeasonExportableByAll(11, MID_SEASON_12_DATE)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canExportSeasonSummary", () => {
|
||||
const baseArgs = {
|
||||
loggedInUser: { id: 1, roles: [] },
|
||||
profileUserId: 1,
|
||||
season: 11,
|
||||
seasonsParticipatedIn: [11, 10],
|
||||
hasCalculatedSkill: true,
|
||||
date: OFF_SEASON_DATE,
|
||||
};
|
||||
|
||||
it("allows the profile owner to export the latest finished season during off-season", () => {
|
||||
expect(SeasonSummary.canExportSeasonSummary(baseArgs)).toBe(true);
|
||||
});
|
||||
|
||||
it("disallows exporting someone else's profile", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({ ...baseArgs, profileUserId: 2 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows without being logged in", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
loggedInUser: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows a season not participated in", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
seasonsParticipatedIn: [10],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows without a calculated skill", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
hasCalculatedSkill: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows a non-supporter exporting an older season", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({ ...baseArgs, season: 10 }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a supporter to export any finished participated season, also mid-season", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
loggedInUser: { id: 1, roles: ["SUPPORTER" as const] },
|
||||
season: 10,
|
||||
date: MID_SEASON_12_DATE,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disallows exporting an ongoing season", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
season: 12,
|
||||
seasonsParticipatedIn: [12, 11, 10],
|
||||
date: MID_SEASON_12_DATE,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disallows a supporter exporting an ongoing season", () => {
|
||||
expect(
|
||||
SeasonSummary.canExportSeasonSummary({
|
||||
...baseArgs,
|
||||
loggedInUser: { id: 1, roles: ["SUPPORTER" as const] },
|
||||
season: 12,
|
||||
seasonsParticipatedIn: [12, 11, 10],
|
||||
date: MID_SEASON_12_DATE,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
195
app/features/img-export/core/SeasonSummary.ts
Normal file
195
app/features/img-export/core/SeasonSummary.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import * as R from "remeda";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import type { Role } from "~/modules/permissions/types";
|
||||
|
||||
const BEST_STAGE_MIN_MAPS_NEEDED = 10;
|
||||
const UNTIERED_TOURNAMENT_TIER = 10;
|
||||
const TOP_WEAPONS_COUNT = 3;
|
||||
const FIELD_STRENGTH_BASELINE_SP = 1200;
|
||||
const FIELD_STRENGTH_SP_PER_POINT = 800;
|
||||
|
||||
export interface SetScore {
|
||||
ownScore: number;
|
||||
opponentScore: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the longest run of consecutive set wins in the given
|
||||
* chronologically ordered list of sets.
|
||||
*/
|
||||
export function longestWinStreak(sets: SetScore[]): number {
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
|
||||
for (const set of sets) {
|
||||
if (set.ownScore > set.opponentScore) {
|
||||
current += 1;
|
||||
longest = Math.max(longest, current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return longest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Win record in sets that went to a deciding map, i.e. the score margin was
|
||||
* exactly one (4-3 in SendouQ, 2-1/3-2 in tournaments).
|
||||
*/
|
||||
export function clutchRecord(sets: SetScore[]): { won: number; total: number } {
|
||||
const decidingMapSets = sets.filter(
|
||||
(set) => Math.abs(set.ownScore - set.opponentScore) === 1,
|
||||
);
|
||||
|
||||
return {
|
||||
won: decidingMapSets.filter((set) => set.ownScore > set.opponentScore)
|
||||
.length,
|
||||
total: decidingMapSets.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage with the best winrate aggregated across modes. Stages with less than
|
||||
* a minimum threshold of maps played are excluded so a lucky 2-0 stage can't
|
||||
* win. Returns undefined when no stage passes the threshold.
|
||||
*/
|
||||
export function bestStage(
|
||||
stages: Partial<
|
||||
Record<
|
||||
StageId,
|
||||
Partial<Record<ModeShort, { wins: number; losses: number }>>
|
||||
>
|
||||
>,
|
||||
): { stageId: StageId; winratePercentage: number } | undefined {
|
||||
let best: { stageId: StageId; winratePercentage: number } | undefined;
|
||||
|
||||
for (const [stageId, modes] of Object.entries(stages)) {
|
||||
let wins = 0;
|
||||
let losses = 0;
|
||||
for (const record of Object.values(modes)) {
|
||||
wins += record.wins;
|
||||
losses += record.losses;
|
||||
}
|
||||
|
||||
const mapsPlayed = wins + losses;
|
||||
if (mapsPlayed < BEST_STAGE_MIN_MAPS_NEEDED) continue;
|
||||
|
||||
const winratePercentage = (wins / mapsPlayed) * 100;
|
||||
if (!best || winratePercentage > best.winratePercentage) {
|
||||
best = { stageId: Number(stageId) as StageId, winratePercentage };
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface TournamentRun {
|
||||
/** Tournament tier, 1 = X (best) … 9 = C. Null when the tournament has no calculated tier (treated as below every tiered tournament). */
|
||||
tier: number | null;
|
||||
placement: number;
|
||||
teamsCount: number;
|
||||
/** Average end of season SP of the players who placed in the tournament's top 8. Null when none of them had a calculated skill. */
|
||||
topEightAvgSp: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite score for ranking a user's tournament runs of a season. The
|
||||
* tournament's tier dominates; within a tier both the user's placement quality
|
||||
* relative to the field size and the strength of that field contribute.
|
||||
*/
|
||||
export function tournamentRunScore(run: TournamentRun): number {
|
||||
const tier = run.tier ?? UNTIERED_TOURNAMENT_TIER;
|
||||
|
||||
return (
|
||||
(10 - tier) * 3 +
|
||||
Math.log2(run.teamsCount / run.placement) +
|
||||
fieldStrengthScore(run.topEightAvgSp)
|
||||
);
|
||||
}
|
||||
|
||||
/** The best tournament run by {@link tournamentRunScore}. */
|
||||
export function bestTournamentRun<T extends TournamentRun>(
|
||||
runs: T[],
|
||||
): T | undefined {
|
||||
return R.firstBy(runs, [tournamentRunScore, "desc"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's most used weapons with their share of all reported weapon
|
||||
* occurrences, most used first.
|
||||
*/
|
||||
export function topWeaponUsages(
|
||||
reportedWeapons: Array<{ weaponSplId: MainWeaponId; count: number }>,
|
||||
): Array<{ weaponSplId: MainWeaponId; usagePercentage: number }> {
|
||||
const totalCount = R.sumBy(reportedWeapons, (weapon) => weapon.count);
|
||||
if (totalCount === 0) return [];
|
||||
|
||||
return reportedWeapons
|
||||
.toSorted((a, b) => b.count - a.count)
|
||||
.slice(0, TOP_WEAPONS_COUNT)
|
||||
.map((weapon) => ({
|
||||
weaponSplId: weapon.weaponSplId,
|
||||
usagePercentage: (weapon.count / totalCount) * 100,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whether the season has ended, a prerequisite for exporting its summary image. */
|
||||
export function isSeasonFinished(season: number, date = new Date()) {
|
||||
return Seasons.allFinished(date).some((nth) => nth === season);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a season's summary image is exportable without the supporter perk:
|
||||
* only the latest finished season is, and only while no season is in progress.
|
||||
*/
|
||||
export function isSeasonExportableByAll(season: number, date = new Date()) {
|
||||
return (
|
||||
Seasons.current(date) === null && Seasons.allFinished(date)[0] === season
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the logged in user can export the season summary image from the
|
||||
* given profile. Only the profile owner can export, they must have
|
||||
* participated in the season and have a calculated (non-approximate) skill
|
||||
* for it. An ongoing season is never exportable. Supporters can export any of
|
||||
* their finished seasons, others only per {@link isSeasonExportableByAll}.
|
||||
*/
|
||||
export function canExportSeasonSummary({
|
||||
loggedInUser,
|
||||
profileUserId,
|
||||
season,
|
||||
seasonsParticipatedIn,
|
||||
hasCalculatedSkill,
|
||||
date = new Date(),
|
||||
}: {
|
||||
loggedInUser?: { id: number; roles: Role[] };
|
||||
profileUserId: number;
|
||||
season: number;
|
||||
seasonsParticipatedIn: number[];
|
||||
hasCalculatedSkill: boolean;
|
||||
date?: Date;
|
||||
}): boolean {
|
||||
if (!loggedInUser || loggedInUser.id !== profileUserId) return false;
|
||||
if (!seasonsParticipatedIn.includes(season)) return false;
|
||||
if (!hasCalculatedSkill) return false;
|
||||
if (!isSeasonFinished(season, date)) return false;
|
||||
if (loggedInUser.roles.includes("SUPPORTER")) return true;
|
||||
|
||||
return isSeasonExportableByAll(season, date);
|
||||
}
|
||||
|
||||
function fieldStrengthScore(topEightAvgSp: number | null) {
|
||||
if (topEightAvgSp === null) return 0;
|
||||
|
||||
return Math.max(
|
||||
0,
|
||||
(topEightAvgSp - FIELD_STRENGTH_BASELINE_SP) / FIELD_STRENGTH_SP_PER_POINT,
|
||||
);
|
||||
}
|
||||
|
|
@ -66,6 +66,11 @@ const PERKS = [
|
|||
name: "previewQ",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "seasonSummaryImage",
|
||||
extraInfo: true,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "userShortLink",
|
||||
|
|
|
|||
|
|
@ -163,34 +163,42 @@ export async function findSeasonProgressionByUserId({
|
|||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.leftJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
|
||||
.leftJoin("Tournament", "Tournament.id", "Skill.tournamentId")
|
||||
.leftJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.leftJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
fn.max("Skill.ordinal").as("ordinal"),
|
||||
sql<string>`date(coalesce("Skill"."createdAt", "GroupMatch"."createdAt", "CalendarEventDate"."startsAt"), 'unixepoch')`.as(
|
||||
"date",
|
||||
return seasonSkillsByDayQuery({ userId, season })
|
||||
.select(({ fn }) => fn.max("Skill.ordinal").as("ordinal"))
|
||||
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Days of the season the user played at least one set on, with whether they
|
||||
* played SendouQ, tournaments or both that day. Dates in `yyyy-MM-dd` format.
|
||||
*/
|
||||
export async function findSeasonActiveDaysByUserId({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}): Promise<Array<{ date: string; activity: "sq" | "tournament" | "both" }>> {
|
||||
const rows = await seasonSkillsByDayQuery({ userId, season })
|
||||
.select([
|
||||
// raw max over a null check: did any of the day's Skill rows come from this source?
|
||||
sql<number>`max("Skill"."groupMatchId" is not null)`.as("playedSq"),
|
||||
sql<number>`max("Skill"."tournamentId" is not null)`.as(
|
||||
"playedTournament",
|
||||
),
|
||||
])
|
||||
.where("Skill.userId", "=", userId)
|
||||
.where("Skill.season", "=", season)
|
||||
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
|
||||
.where(({ or, eb }) =>
|
||||
or([
|
||||
eb("GroupMatch.id", "is not", null),
|
||||
eb("Tournament.id", "is not", null),
|
||||
]),
|
||||
)
|
||||
.groupBy("date")
|
||||
.orderBy("date", "asc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
date: row.date,
|
||||
activity:
|
||||
row.playedSq && row.playedTournament
|
||||
? ("both" as const)
|
||||
: row.playedSq
|
||||
? ("sq" as const)
|
||||
: ("tournament" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,3 +224,42 @@ function latestSkillsOfSeason(
|
|||
])
|
||||
.where("season", "=", season);
|
||||
}
|
||||
|
||||
/**
|
||||
* User's Skill rows of a season that came from a set played, grouped by the day
|
||||
* it was played on (`yyyy-MM-dd`) in ascending order. Callers select what they
|
||||
* want aggregated per day.
|
||||
*/
|
||||
function seasonSkillsByDayQuery({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.leftJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
|
||||
.leftJoin("Tournament", "Tournament.id", "Skill.tournamentId")
|
||||
.leftJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.leftJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select(
|
||||
sql<string>`date(coalesce("Skill"."createdAt", "GroupMatch"."createdAt", "CalendarEventDate"."startsAt"), 'unixepoch')`.as(
|
||||
"date",
|
||||
),
|
||||
)
|
||||
.where("Skill.userId", "=", userId)
|
||||
.where("Skill.season", "=", season)
|
||||
.where(({ or, eb }) =>
|
||||
or([
|
||||
eb("GroupMatch.id", "is not", null),
|
||||
eb("Tournament.id", "is not", null),
|
||||
]),
|
||||
)
|
||||
.groupBy("date")
|
||||
.orderBy("date", "asc");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
import { sql, type Transaction } from "kysely";
|
||||
import {
|
||||
type ExpressionBuilder,
|
||||
type NotNull,
|
||||
sql,
|
||||
type Transaction,
|
||||
} from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { commonUserJsonObject } from "~/utils/kysely.server";
|
||||
import {
|
||||
commonUserJsonObject,
|
||||
tournamentLogoWithDefault,
|
||||
} from "~/utils/kysely.server";
|
||||
|
||||
const BEST_SET_OPPONENTS_WITH_SKILL_NEEDED = 2;
|
||||
/** How many extra SendouQ sets to consider so that the roster deduplication still has enough left to return. */
|
||||
const BEST_SET_CANDIDATES_PER_RESULT = 10;
|
||||
const TOURNAMENT_FIELD_STRENGTH_PLACEMENT = 8;
|
||||
|
||||
export function upsertMapResults(
|
||||
results: Pick<
|
||||
|
|
@ -134,6 +149,271 @@ export async function findSeasonMatesEnemiesByUserId({
|
|||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chronological (oldest first) scores of every set the user played in a given
|
||||
* season, both SendouQ matches and ranked tournaments.
|
||||
*/
|
||||
export async function findSeasonSetScoresByUserId(args: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}): Promise<Array<{ ownScore: number; opponentScore: number }>> {
|
||||
const [sqSets, tournamentSets] = await Promise.all([
|
||||
sqSetScoresQuery(args)
|
||||
.select("GroupMatch.createdAt as playedAt")
|
||||
.groupBy("GroupMatch.id")
|
||||
.execute(),
|
||||
db
|
||||
.selectFrom(tournamentSetsQuery(args).as("TournamentSet"))
|
||||
.select((eb) => [
|
||||
"TournamentSet.playedAt",
|
||||
tournamentSetGamesWonCount(eb, "own").as("ownScore"),
|
||||
tournamentSetGamesWonCount(eb, "opponent").as("opponentScore"),
|
||||
])
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return [...sqSets, ...tournamentSets]
|
||||
.sort((a, b) => a.playedAt - b.playedAt)
|
||||
.map((set) => ({
|
||||
ownScore: set.ownScore,
|
||||
opponentScore: set.opponentScore,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's won sets of a season ranked by the average opponent skill
|
||||
* (ordinal) at the time the set was played, best first. Covers both SendouQ
|
||||
* matches and ranked tournaments; tournament sets are skipped unless at least
|
||||
* two opponents have a calculated skill for that tournament. Only the best set
|
||||
* against any given opponent roster is included.
|
||||
*/
|
||||
export async function findSeasonBestSetsByUserId({
|
||||
userId,
|
||||
season,
|
||||
limit,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
limit: number;
|
||||
}) {
|
||||
const [sqSets, tournamentSets] = await Promise.all([
|
||||
db
|
||||
.selectFrom(
|
||||
sqSetScoresQuery({ userId, season })
|
||||
.select([
|
||||
"GroupMatch.id as groupMatchId",
|
||||
// raw iif: the opposing group is whichever side of the match the user's group is not
|
||||
sql<number>`iif("OwnMember"."groupId" = "GroupMatch"."alphaGroupId", "GroupMatch"."bravoGroupId", "GroupMatch"."alphaGroupId")`.as(
|
||||
"opponentGroupId",
|
||||
),
|
||||
])
|
||||
.groupBy("GroupMatch.id")
|
||||
.as("SqSet"),
|
||||
)
|
||||
.select((eb) => [
|
||||
"SqSet.ownScore",
|
||||
"SqSet.opponentScore",
|
||||
eb
|
||||
.selectFrom("Skill as OpponentSkill")
|
||||
.select(({ fn }) =>
|
||||
fn.avg<number>("OpponentSkill.ordinal").as("average"),
|
||||
)
|
||||
.whereRef("OpponentSkill.groupMatchId", "=", "SqSet.groupMatchId")
|
||||
.where((ieb) =>
|
||||
ieb(
|
||||
"OpponentSkill.userId",
|
||||
"in",
|
||||
ieb
|
||||
.selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "SqSet.opponentGroupId"),
|
||||
),
|
||||
)
|
||||
.as("avgOpponentOrdinal"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select(["User.id", "User.username", "User.country"])
|
||||
.whereRef("GroupMember.groupId", "=", "SqSet.opponentGroupId")
|
||||
.orderBy("User.username", "asc"),
|
||||
).as("opponentPlayers"),
|
||||
])
|
||||
.whereRef("SqSet.ownScore", ">", "SqSet.opponentScore")
|
||||
.orderBy("avgOpponentOrdinal", "desc")
|
||||
.limit(limit * BEST_SET_CANDIDATES_PER_RESULT)
|
||||
.execute(),
|
||||
db
|
||||
.selectFrom(tournamentSetsQuery({ userId, season }).as("TournamentSet"))
|
||||
.innerJoin(
|
||||
"CalendarEvent",
|
||||
"CalendarEvent.tournamentId",
|
||||
"TournamentSet.tournamentId",
|
||||
)
|
||||
.select((eb) => {
|
||||
const opponentUserIds = eb
|
||||
.selectFrom(
|
||||
"TournamentMatchGameResultParticipant as OpponentParticipant",
|
||||
)
|
||||
.innerJoin(
|
||||
"TournamentMatchGameResult as OpponentGameResult",
|
||||
"OpponentGameResult.id",
|
||||
"OpponentParticipant.matchGameResultId",
|
||||
)
|
||||
.select("OpponentParticipant.userId")
|
||||
.distinct()
|
||||
.whereRef(
|
||||
"OpponentGameResult.matchId",
|
||||
"=",
|
||||
"TournamentSet.tournamentMatchId",
|
||||
)
|
||||
.whereRef(
|
||||
"OpponentParticipant.tournamentTeamId",
|
||||
"!=",
|
||||
"TournamentSet.ownTeamId",
|
||||
);
|
||||
|
||||
return [
|
||||
tournamentSetGamesWonCount(eb, "own").as("ownScore"),
|
||||
tournamentSetGamesWonCount(eb, "opponent").as("opponentScore"),
|
||||
"CalendarEvent.name as tournamentName",
|
||||
eb
|
||||
.selectFrom("Skill as OpponentSkill")
|
||||
.select(({ fn }) =>
|
||||
fn.avg<number>("OpponentSkill.ordinal").as("average"),
|
||||
)
|
||||
.whereRef(
|
||||
"OpponentSkill.tournamentId",
|
||||
"=",
|
||||
"TournamentSet.tournamentId",
|
||||
)
|
||||
.where("OpponentSkill.userId", "in", opponentUserIds)
|
||||
.as("avgOpponentOrdinal"),
|
||||
eb
|
||||
.selectFrom("Skill as OpponentSkill")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.whereRef(
|
||||
"OpponentSkill.tournamentId",
|
||||
"=",
|
||||
"TournamentSet.tournamentId",
|
||||
)
|
||||
.where("OpponentSkill.userId", "in", opponentUserIds)
|
||||
.$asScalar()
|
||||
.$notNull()
|
||||
.as("opponentsWithSkillCount"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("User")
|
||||
.select(["User.id", "User.username", "User.country"])
|
||||
.where("User.id", "in", opponentUserIds)
|
||||
.orderBy("User.username", "asc"),
|
||||
).as("opponentPlayers"),
|
||||
];
|
||||
})
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
const sets = [
|
||||
...sqSets.flatMap((set) =>
|
||||
set.avgOpponentOrdinal === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
ownScore: set.ownScore,
|
||||
opponentScore: set.opponentScore,
|
||||
avgOpponentOrdinal: set.avgOpponentOrdinal,
|
||||
opponentPlayers: set.opponentPlayers,
|
||||
tournamentName: null,
|
||||
},
|
||||
],
|
||||
),
|
||||
...tournamentSets.flatMap((set) =>
|
||||
set.avgOpponentOrdinal === null ||
|
||||
set.ownScore <= set.opponentScore ||
|
||||
set.opponentsWithSkillCount < BEST_SET_OPPONENTS_WITH_SKILL_NEEDED
|
||||
? []
|
||||
: [
|
||||
{
|
||||
ownScore: set.ownScore,
|
||||
opponentScore: set.opponentScore,
|
||||
avgOpponentOrdinal: set.avgOpponentOrdinal,
|
||||
opponentPlayers: set.opponentPlayers,
|
||||
tournamentName: set.tournamentName,
|
||||
},
|
||||
],
|
||||
),
|
||||
].sort((a, b) => b.avgOpponentOrdinal - a.avgOpponentOrdinal);
|
||||
|
||||
// keeps the best set played against each distinct set of opponents
|
||||
return R.uniqueBy(sets, (set) =>
|
||||
set.opponentPlayers
|
||||
.map((player) => player.id)
|
||||
.toSorted((a, b) => a - b)
|
||||
.join("-"),
|
||||
).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's ranked tournament results of a season with the tournament's tier,
|
||||
* field size and the average end of season skill (ordinal) of its top 8 placing
|
||||
* players, for picking their best tournament run.
|
||||
*/
|
||||
export async function findSeasonTournamentRunsByUserId({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.innerJoin("TournamentResult", (join) =>
|
||||
join
|
||||
.onRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
|
||||
.on("TournamentResult.userId", "=", userId),
|
||||
)
|
||||
.innerJoin("Tournament", "Tournament.id", "Skill.tournamentId")
|
||||
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
|
||||
.select((eb) => [
|
||||
"TournamentResult.placement",
|
||||
"TournamentResult.participantCount as teamsCount",
|
||||
"Tournament.tier",
|
||||
"CalendarEvent.name",
|
||||
tournamentLogoWithDefault(eb).as("logoUrl"),
|
||||
eb
|
||||
.selectFrom(
|
||||
eb
|
||||
.selectFrom("Skill as TopEightSkill")
|
||||
.select((seb) => [
|
||||
"TopEightSkill.ordinal",
|
||||
// bare column with max(): the ordinal comes from the season's last skill row of that user
|
||||
seb.fn.max("TopEightSkill.id").as("latestId"),
|
||||
])
|
||||
.where("TopEightSkill.season", "=", season)
|
||||
.where("TopEightSkill.userId", "in", (ieb) =>
|
||||
ieb
|
||||
.selectFrom("TournamentResult as TopEightResult")
|
||||
.select("TopEightResult.userId")
|
||||
.whereRef("TopEightResult.tournamentId", "=", "Tournament.id")
|
||||
.where(
|
||||
"TopEightResult.placement",
|
||||
"<=",
|
||||
TOURNAMENT_FIELD_STRENGTH_PLACEMENT,
|
||||
),
|
||||
)
|
||||
.groupBy("TopEightSkill.userId")
|
||||
.as("TopEightLatestSkill"),
|
||||
)
|
||||
.select(({ fn }) =>
|
||||
fn.avg<number>("TopEightLatestSkill.ordinal").as("average"),
|
||||
)
|
||||
.as("topEightAvgOrdinal"),
|
||||
])
|
||||
.where("Skill.userId", "=", userId)
|
||||
.where("Skill.season", "=", season)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function upsertPlayerResults(
|
||||
results: Tables["PlayerResult"][],
|
||||
trx?: Transaction<DB>,
|
||||
|
|
@ -165,3 +445,120 @@ export function upsertPlayerResults(
|
|||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* SendouQ sets of the user's season with the map score summed per match.
|
||||
* Callers add their extra selects and finish with a `groupBy("GroupMatch.id")`.
|
||||
*/
|
||||
function sqSetScoresQuery({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.innerJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
|
||||
.innerJoin("GroupMember as OwnMember", (join) =>
|
||||
join
|
||||
.onRef("OwnMember.userId", "=", "Skill.userId")
|
||||
.on((eb) =>
|
||||
eb("OwnMember.groupId", "in", [
|
||||
eb.ref("GroupMatch.alphaGroupId"),
|
||||
eb.ref("GroupMatch.bravoGroupId"),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.innerJoin("GroupMatchMap", "GroupMatchMap.matchId", "GroupMatch.id")
|
||||
.select([
|
||||
// raw sums of comparisons: counts the maps won by each side of the match
|
||||
sql<number>`sum("GroupMatchMap"."winnerGroupId" = "OwnMember"."groupId")`.as(
|
||||
"ownScore",
|
||||
),
|
||||
sql<number>`sum("GroupMatchMap"."winnerGroupId" is not null and "GroupMatchMap"."winnerGroupId" != "OwnMember"."groupId")`.as(
|
||||
"opponentScore",
|
||||
),
|
||||
])
|
||||
.where("Skill.userId", "=", userId)
|
||||
.where("Skill.season", "=", season);
|
||||
}
|
||||
|
||||
interface SeasonTournamentSet {
|
||||
tournamentMatchId: number;
|
||||
tournamentId: number;
|
||||
ownTeamId: number;
|
||||
playedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per set the user played in the season's ranked tournaments (the
|
||||
* tournaments they have a `Skill` row for).
|
||||
*/
|
||||
function tournamentSetsQuery({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
return db
|
||||
.selectFrom("TournamentMatchGameResultParticipant as Participant")
|
||||
.innerJoin(
|
||||
"TournamentMatchGameResult as GameResult",
|
||||
"GameResult.id",
|
||||
"Participant.matchGameResultId",
|
||||
)
|
||||
.innerJoin("TournamentMatch", "TournamentMatch.id", "GameResult.matchId")
|
||||
.innerJoin(
|
||||
"TournamentStage",
|
||||
"TournamentStage.id",
|
||||
"TournamentMatch.stageId",
|
||||
)
|
||||
.select(({ fn }) => [
|
||||
"TournamentMatch.id as tournamentMatchId",
|
||||
"TournamentStage.tournamentId",
|
||||
"Participant.tournamentTeamId as ownTeamId",
|
||||
fn.max("GameResult.createdAt").as("playedAt"),
|
||||
])
|
||||
.where("Participant.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
"TournamentStage.tournamentId",
|
||||
"in",
|
||||
eb
|
||||
.selectFrom("Skill")
|
||||
.select("Skill.tournamentId")
|
||||
.where("Skill.userId", "=", userId)
|
||||
.where("Skill.season", "=", season)
|
||||
.where("Skill.tournamentId", "is not", null)
|
||||
.$narrowType<{ tournamentId: NotNull }>(),
|
||||
),
|
||||
)
|
||||
.groupBy("TournamentMatch.id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Games of a tournament set won by either side, counted over every game of the
|
||||
* set since the user may have sat out some of them. Correlates on a
|
||||
* `tournamentSetsQuery` aliased as `"TournamentSet"`.
|
||||
*/
|
||||
function tournamentSetGamesWonCount(
|
||||
eb: ExpressionBuilder<
|
||||
DB & { TournamentSet: SeasonTournamentSet },
|
||||
"TournamentSet"
|
||||
>,
|
||||
side: "own" | "opponent",
|
||||
) {
|
||||
return eb
|
||||
.selectFrom("TournamentMatchGameResult as SetGame")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.whereRef("SetGame.matchId", "=", "TournamentSet.tournamentMatchId")
|
||||
.whereRef(
|
||||
"SetGame.winnerTeamId",
|
||||
side === "own" ? "=" : "!=",
|
||||
"TournamentSet.ownTeamId",
|
||||
)
|
||||
.$asScalar()
|
||||
.$notNull();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
.title {
|
||||
overflow: hidden;
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-extra);
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
|
||||
/* the author credit belongs visually to the title above it, counteract the container gap */
|
||||
.title + .author {
|
||||
margin-top: calc(-1 * var(--s-3));
|
||||
}
|
||||
|
||||
/* without a title the lone author credit would leave the graphic looking top heavy */
|
||||
.author:first-child {
|
||||
margin-top: calc(-1 * var(--s-2));
|
||||
}
|
||||
|
||||
.authorBy {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-semi);
|
||||
text-transform: lowercase;
|
||||
color: var(--graphic-text-dim);
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-extra);
|
||||
}
|
||||
|
||||
.tiers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.tierRow {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--s-2);
|
||||
min-height: 64px;
|
||||
padding: var(--s-1-5);
|
||||
background-color: var(--graphic-row-bg);
|
||||
border: 1.5px solid var(--graphic-row-border);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
.tierLabel {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 68px;
|
||||
max-width: 110px;
|
||||
padding: var(--s-2);
|
||||
font-weight: var(--weight-bold);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
/* tier label backgrounds are bright preset colors in both themes */
|
||||
color: oklch(17% 0.03 268);
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
.tierItems {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
}
|
||||
64
app/features/tier-list-maker/components/TierListGraphic.tsx
Normal file
64
app/features/tier-list-maker/components/TierListGraphic.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { GraphicContainer } from "~/features/img-export/components/Graphic";
|
||||
import type {
|
||||
TierListItem,
|
||||
TierListMakerTier,
|
||||
} from "../tier-list-maker-schemas";
|
||||
import { tierListItemId, tierNameFontSize } from "../tier-list-maker-utils";
|
||||
import styles from "./TierListGraphic.module.css";
|
||||
import { TierListItemImage } from "./TierListItemImage";
|
||||
|
||||
export interface TierListGraphicAuthor {
|
||||
username: string;
|
||||
discordId: string;
|
||||
discordAvatar: string | null;
|
||||
}
|
||||
|
||||
export function TierListGraphic({
|
||||
title,
|
||||
author,
|
||||
tiers,
|
||||
showTierHeaders,
|
||||
}: {
|
||||
title: string;
|
||||
author?: TierListGraphicAuthor;
|
||||
tiers: Array<TierListMakerTier & { items: TierListItem[] }>;
|
||||
showTierHeaders: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["tier-list-maker"]);
|
||||
|
||||
return (
|
||||
<GraphicContainer>
|
||||
{title ? <div className={styles.title}>{title}</div> : null}
|
||||
{author ? (
|
||||
<div className={styles.author}>
|
||||
<span className={styles.authorBy}>{t("tier-list-maker:by")}</span>
|
||||
<Avatar user={author} size="xxxs" alt="" />
|
||||
<span className={styles.authorName}>{author.username}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.tiers}>
|
||||
{tiers.map((tier) => (
|
||||
<div key={tier.id} className={styles.tierRow}>
|
||||
{showTierHeaders ? (
|
||||
<div
|
||||
className={styles.tierLabel}
|
||||
style={{ backgroundColor: tier.color }}
|
||||
>
|
||||
<span style={{ fontSize: tierNameFontSize(tier.name) }}>
|
||||
{tier.name}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.tierItems}>
|
||||
{tier.items.map((item) => (
|
||||
<TierListItemImage key={tierListItemId(item)} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GraphicContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,12 +14,10 @@ import { SendouPopover } from "~/components/elements/Popover";
|
|||
import { useTierListState } from "../contexts/TierListContext";
|
||||
import {
|
||||
PRESET_COLORS,
|
||||
TIER_NAME_FONT_SIZE_BREAKPOINTS,
|
||||
TIER_NAME_FONT_SIZE_MIN,
|
||||
TIER_NAME_MAX_LENGTH,
|
||||
} from "../tier-list-maker-constants";
|
||||
import type { TierListMakerTier } from "../tier-list-maker-schemas";
|
||||
import { tierListItemId } from "../tier-list-maker-utils";
|
||||
import { tierListItemId, tierNameFontSize } from "../tier-list-maker-utils";
|
||||
import { DraggableItem } from "./DraggableItem";
|
||||
import styles from "./TierRow.module.css";
|
||||
|
||||
|
|
@ -38,7 +36,6 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
handleMoveTierUp,
|
||||
handleMoveTierDown,
|
||||
showTierHeaders,
|
||||
screenshotMode,
|
||||
placementMode,
|
||||
selectedTierId,
|
||||
setSelectedTierId,
|
||||
|
|
@ -60,8 +57,7 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
const isLastTier = tierIndex === state.tiers.length - 1;
|
||||
|
||||
const isClickMode = placementMode === "click";
|
||||
const isSelected =
|
||||
isClickMode && !screenshotMode && selectedTierId === tier.id;
|
||||
const isSelected = isClickMode && selectedTierId === tier.id;
|
||||
|
||||
const selectTierProps = isClickMode
|
||||
? {
|
||||
|
|
@ -151,18 +147,14 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
|
||||
<div
|
||||
ref={combinedRef}
|
||||
style={{
|
||||
borderRadius: screenshotMode ? "var(--radius-field)" : undefined,
|
||||
transition: screenshotMode ? "none" : undefined,
|
||||
}}
|
||||
className={clsx(styles.targetZone, {
|
||||
[styles.targetZoneOver]: isOver,
|
||||
[styles.targetZoneSelectable]: isClickMode && !screenshotMode,
|
||||
[styles.targetZoneSelectable]: isClickMode,
|
||||
[styles.targetZoneSelected]: isSelected,
|
||||
})}
|
||||
{...selectTierProps}
|
||||
>
|
||||
{items.length === 0 && !screenshotMode ? (
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
{isClickMode
|
||||
? t("tier-list-maker:clickToAdd")
|
||||
|
|
@ -180,28 +172,26 @@ export function TierRow({ tier }: TierRowProps) {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{!screenshotMode ? (
|
||||
<div className={styles.arrowControls}>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
|
||||
onClick={() => handleMoveTierUp(tier.id)}
|
||||
disabled={isFirstTier}
|
||||
type="button"
|
||||
aria-label="Move tier up"
|
||||
>
|
||||
<ChevronUp className={styles.arrowIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
|
||||
onClick={() => handleMoveTierDown(tier.id)}
|
||||
disabled={isLastTier}
|
||||
type="button"
|
||||
aria-label="Move tier down"
|
||||
>
|
||||
<ChevronDown className={styles.arrowIcon} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.arrowControls}>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonUpper)}
|
||||
onClick={() => handleMoveTierUp(tier.id)}
|
||||
disabled={isFirstTier}
|
||||
type="button"
|
||||
aria-label="Move tier up"
|
||||
>
|
||||
<ChevronUp className={styles.arrowIcon} />
|
||||
</button>
|
||||
<button
|
||||
className={clsx(styles.arrowButton, styles.arrowButtonLower)}
|
||||
onClick={() => handleMoveTierDown(tier.id)}
|
||||
disabled={isLastTier}
|
||||
type="button"
|
||||
aria-label="Move tier down"
|
||||
>
|
||||
<ChevronDown className={styles.arrowIcon} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -248,13 +238,3 @@ function useLockedHeightWhileDragging({
|
|||
|
||||
return combinedRef;
|
||||
}
|
||||
|
||||
function tierNameFontSize(name: string) {
|
||||
const length = name.length;
|
||||
for (const breakpoint of TIER_NAME_FONT_SIZE_BREAKPOINTS) {
|
||||
if (length <= breakpoint.maxLength) {
|
||||
return breakpoint.fontSize;
|
||||
}
|
||||
}
|
||||
return TIER_NAME_FONT_SIZE_MIN;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,16 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { useTierList } from "../hooks/useTierList";
|
||||
|
||||
type TierListContextType = ReturnType<typeof useTierList> & {
|
||||
screenshotMode: boolean;
|
||||
setScreenshotMode: (value: boolean) => void;
|
||||
};
|
||||
type TierListContextType = ReturnType<typeof useTierList>;
|
||||
|
||||
const TierListContext = createContext<TierListContextType | null>(null);
|
||||
|
||||
export function TierListProvider({ children }: { children: ReactNode }) {
|
||||
const state = useTierList();
|
||||
const [screenshotMode, setScreenshotMode] = useState(false);
|
||||
|
||||
return (
|
||||
<TierListContext.Provider
|
||||
value={{
|
||||
...state,
|
||||
screenshotMode,
|
||||
setScreenshotMode,
|
||||
}}
|
||||
>
|
||||
<TierListContext.Provider value={state}>
|
||||
{children}
|
||||
</TierListContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,10 @@ import {
|
|||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { modeShort, safeJSONParse } from "~/utils/zod";
|
||||
import { DEFAULT_TIERS } from "../tier-list-maker-constants";
|
||||
import {
|
||||
DEFAULT_TIERS,
|
||||
TIER_LIST_SEARCH_PARAM_NAMES,
|
||||
} from "../tier-list-maker-constants";
|
||||
import {
|
||||
type TierListItem,
|
||||
type TierListMakerTier,
|
||||
|
|
@ -32,9 +35,9 @@ import {
|
|||
} from "../tier-list-maker-schemas";
|
||||
import {
|
||||
addItemToTier,
|
||||
compress,
|
||||
decompress,
|
||||
getNextNthForItem,
|
||||
serializeTierListState,
|
||||
} from "../tier-list-maker-utils";
|
||||
|
||||
export type TierListPlacementMode = "track" | "click";
|
||||
|
|
@ -83,13 +86,13 @@ export function useTierList() {
|
|||
});
|
||||
|
||||
const [showTierHeaders, setShowTierHeaders] = useSearchParamState({
|
||||
name: "showTierHeaders",
|
||||
name: TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
|
||||
defaultValue: true,
|
||||
revive: (value) => value === "true",
|
||||
});
|
||||
|
||||
const [title, setTitle] = useSearchParamState({
|
||||
name: "title",
|
||||
name: TIER_LIST_SEARCH_PARAM_NAMES.TITLE,
|
||||
defaultValue: "",
|
||||
revive: (value) => value,
|
||||
});
|
||||
|
|
@ -512,12 +515,10 @@ export function useTierList() {
|
|||
};
|
||||
}
|
||||
|
||||
const TIER_SEARCH_PARAM_NAME = "state";
|
||||
|
||||
function useSearchParamTiersState() {
|
||||
const [initialSearchParams] = useSearchParams();
|
||||
const [tiers, setTiers] = React.useState<TierListState>(() => {
|
||||
const param = initialSearchParams.get(TIER_SEARCH_PARAM_NAME);
|
||||
const param = initialSearchParams.get(TIER_LIST_SEARCH_PARAM_NAMES.STATE);
|
||||
|
||||
try {
|
||||
if (param) {
|
||||
|
|
@ -543,11 +544,8 @@ function useSearchParamTiersState() {
|
|||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
searchParams.set(
|
||||
TIER_SEARCH_PARAM_NAME,
|
||||
compress({
|
||||
tiers: state.tiers,
|
||||
tierItems: Array.from(state.tierItems.entries()),
|
||||
}),
|
||||
TIER_LIST_SEARCH_PARAM_NAMES.STATE,
|
||||
serializeTierListState(state),
|
||||
);
|
||||
|
||||
window.history.replaceState(
|
||||
|
|
|
|||
|
|
@ -15,12 +15,6 @@
|
|||
margin-bottom: var(--s-6);
|
||||
}
|
||||
|
||||
.tierListScreenshotMode {
|
||||
padding: var(--s-4);
|
||||
max-width: 720px;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: var(--s-4);
|
||||
|
|
@ -31,56 +25,3 @@
|
|||
row-gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
padding: 0 var(--s-2) var(--s-2) var(--s-2);
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-high);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:focus::placeholder {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.authorSection {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--s-0-5);
|
||||
margin-bottom: var(--s-3);
|
||||
margin-top: -15px;
|
||||
}
|
||||
|
||||
.authorBy {
|
||||
font-size: var(--font-2xs);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--color-text-high);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.authorInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.authorUsername {
|
||||
font-weight: var(--weight-bold);
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,9 @@ import {
|
|||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import clsx from "clsx";
|
||||
import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import {
|
||||
SendouChipRadio,
|
||||
|
|
@ -33,6 +31,7 @@ import { ModeImage } from "~/components/Image";
|
|||
import { Main } from "~/components/Main";
|
||||
import { Placeholder } from "~/components/Placeholder";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
|
@ -40,6 +39,7 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
|||
import { navIconUrl, TIER_LIST_MAKER_URL } from "~/utils/urls";
|
||||
import { ItemDragPreview } from "../components/ItemDragPreview";
|
||||
import { ItemPool } from "../components/ItemPool";
|
||||
import { TierListGraphic } from "../components/TierListGraphic";
|
||||
import { TierRow } from "../components/TierRow";
|
||||
import {
|
||||
TierListProvider,
|
||||
|
|
@ -47,6 +47,7 @@ import {
|
|||
} from "../contexts/TierListContext";
|
||||
import type { TierListPlacementMode } from "../hooks/useTierList";
|
||||
import type { TierListItem } from "../tier-list-maker-schemas";
|
||||
import { tierListMakerPathWithState } from "../tier-list-maker-utils";
|
||||
import styles from "./tier-list-maker.module.css";
|
||||
|
||||
const PLACEMENT_MODES: TierListPlacementMode[] = ["click", "track"];
|
||||
|
|
@ -89,7 +90,6 @@ export default function TierListMakerPage() {
|
|||
|
||||
function TierListMakerContent() {
|
||||
const { t } = useTranslation(["tier-list-maker"]);
|
||||
const user = useUser();
|
||||
|
||||
const {
|
||||
itemType,
|
||||
|
|
@ -109,10 +109,6 @@ function TierListMakerContent() {
|
|||
setCanAddDuplicates,
|
||||
showTierHeaders,
|
||||
setShowTierHeaders,
|
||||
title,
|
||||
setTitle,
|
||||
screenshotMode,
|
||||
setScreenshotMode,
|
||||
selectedModes,
|
||||
setSelectedModes,
|
||||
placementMode,
|
||||
|
|
@ -132,27 +128,6 @@ function TierListMakerContent() {
|
|||
}),
|
||||
);
|
||||
|
||||
const tierListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!tierListRef.current) return;
|
||||
|
||||
const { snapdom } = await import("@zumer/snapdom");
|
||||
|
||||
flushSync(() => setScreenshotMode(true));
|
||||
|
||||
await snapdom.download(tierListRef.current, {
|
||||
type: "png",
|
||||
filename: "tier-list",
|
||||
quality: 1,
|
||||
scale: 1.75,
|
||||
embedFonts: true,
|
||||
backgroundColor: getComputedStyle(document.body).backgroundColor,
|
||||
});
|
||||
|
||||
setScreenshotMode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Main bigger className={clsx(styles.container, "stack lg")}>
|
||||
<div className={styles.header}>
|
||||
|
|
@ -160,13 +135,7 @@ function TierListMakerContent() {
|
|||
<SendouButton onPress={handleAddTier} size="small" icon={<Plus />}>
|
||||
{t("tier-list-maker:addTier")}
|
||||
</SendouButton>
|
||||
<SendouButton
|
||||
onPress={handleDownload}
|
||||
size="small"
|
||||
icon={<HardDriveDownload />}
|
||||
>
|
||||
{t("tier-list-maker:download")}
|
||||
</SendouButton>
|
||||
<TierListExportDialog />
|
||||
</div>
|
||||
<ResetPopover key={state.tierItems.size} handleReset={handleReset} />
|
||||
</div>
|
||||
|
|
@ -180,30 +149,7 @@ function TierListMakerContent() {
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="stack">
|
||||
<div
|
||||
className={clsx(styles.tierList, {
|
||||
[styles.tierListScreenshotMode]: screenshotMode,
|
||||
})}
|
||||
ref={tierListRef}
|
||||
>
|
||||
{title || !screenshotMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("tier-list-maker:titlePlaceholder")}
|
||||
className={clsx(styles.titleInput, "plain")}
|
||||
/>
|
||||
) : null}
|
||||
{screenshotMode && title && user ? (
|
||||
<div className={styles.authorSection}>
|
||||
<div className={styles.authorBy}>{t("tier-list-maker:by")}</div>
|
||||
<div className={styles.authorInfo}>
|
||||
<Avatar user={user} size="xxxs" alt={user.username} />
|
||||
<span className={styles.authorUsername}>{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.tierList}>
|
||||
{state.tiers.map((tier) => (
|
||||
<TierRow key={tier.id} tier={tier} />
|
||||
))}
|
||||
|
|
@ -341,6 +287,57 @@ function TierListMakerContent() {
|
|||
);
|
||||
}
|
||||
|
||||
function TierListExportDialog() {
|
||||
const { t } = useTranslation(["tier-list-maker", "common"]);
|
||||
const user = useUser();
|
||||
const { state, getItemsInTier, showTierHeaders, title, setTitle } =
|
||||
useTierListState();
|
||||
const [showUsername, setShowUsername] = useState(true);
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
trigger={
|
||||
<SendouButton size="small" icon={<HardDriveDownload />}>
|
||||
{t("common:imageExport.export")}
|
||||
</SendouButton>
|
||||
}
|
||||
heading={t("common:imageExport.export")}
|
||||
filename="tier-list"
|
||||
qrCodePath={tierListMakerPathWithState({
|
||||
state,
|
||||
title,
|
||||
showTierHeaders,
|
||||
})}
|
||||
settings={
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("tier-list-maker:title")}
|
||||
aria-label={t("tier-list-maker:title")}
|
||||
/>
|
||||
{user ? (
|
||||
<SendouSwitch isSelected={showUsername} onChange={setShowUsername}>
|
||||
{t("tier-list-maker:showUsername")}
|
||||
</SendouSwitch>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TierListGraphic
|
||||
title={title}
|
||||
author={showUsername && user ? user : undefined}
|
||||
tiers={state.tiers.map((tier) => ({
|
||||
...tier,
|
||||
items: getItemsInTier(tier.id),
|
||||
}))}
|
||||
showTierHeaders={showTierHeaders}
|
||||
/>
|
||||
</ImageExportDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetPopover({ handleReset }: { handleReset: () => void }) {
|
||||
const { t } = useTranslation(["tier-list-maker", "common"]);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@ import type { TierListMakerTier } from "./tier-list-maker-schemas";
|
|||
|
||||
export const TIER_NAME_MAX_LENGTH = 50;
|
||||
|
||||
/** Search params that hold the tier list itself, shared by the maker page and the links pointing back to it */
|
||||
export const TIER_LIST_SEARCH_PARAM_NAMES = {
|
||||
STATE: "state",
|
||||
TITLE: "title",
|
||||
SHOW_TIER_HEADERS: "showTierHeaders",
|
||||
} as const;
|
||||
|
||||
export const TIER_NAME_FONT_SIZE_BREAKPOINTS = [
|
||||
{ maxLength: 3, fontSize: "var(--font-xl)" },
|
||||
{ maxLength: 8, fontSize: "var(--font-lg)" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { TIER_LIST_SEARCH_PARAM_NAMES } from "./tier-list-maker-constants";
|
||||
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
|
||||
import { tierListStateSerializedSchema } from "./tier-list-maker-schemas";
|
||||
import {
|
||||
addItemToTier,
|
||||
decompress,
|
||||
getNextNthForItem,
|
||||
tierListItemId,
|
||||
tierListMakerPathWithState,
|
||||
} from "./tier-list-maker-utils";
|
||||
|
||||
function makeState(
|
||||
|
|
@ -80,6 +84,68 @@ describe("getNextNthForItem", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("tierListMakerPathWithState", () => {
|
||||
function parseStateFromPath(path: string): TierListState {
|
||||
const searchParams = new URLSearchParams(path.split("?")[1]);
|
||||
const param = searchParams.get(TIER_LIST_SEARCH_PARAM_NAMES.STATE);
|
||||
|
||||
const parsed = tierListStateSerializedSchema.parse(decompress(param!));
|
||||
|
||||
return { tiers: parsed.tiers, tierItems: new Map(parsed.tierItems) };
|
||||
}
|
||||
|
||||
it("round trips the tier list state", () => {
|
||||
const state = makeState({
|
||||
"tier-a": [splattershot, { ...splatRoller, nth: 2 }],
|
||||
"tier-b": [splatRoller],
|
||||
});
|
||||
|
||||
const path = tierListMakerPathWithState({
|
||||
state,
|
||||
title: "",
|
||||
showTierHeaders: true,
|
||||
});
|
||||
|
||||
expect(parseStateFromPath(path)).toEqual(state);
|
||||
});
|
||||
|
||||
it("includes the title", () => {
|
||||
const path = tierListMakerPathWithState({
|
||||
state: makeState(),
|
||||
title: "Weapons ranked & sorted",
|
||||
showTierHeaders: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
new URLSearchParams(path.split("?")[1]).get(
|
||||
TIER_LIST_SEARCH_PARAM_NAMES.TITLE,
|
||||
),
|
||||
).toBe("Weapons ranked & sorted");
|
||||
});
|
||||
|
||||
it("only includes tier headers param when they are hidden", () => {
|
||||
const withHeaders = tierListMakerPathWithState({
|
||||
state: makeState(),
|
||||
title: "",
|
||||
showTierHeaders: true,
|
||||
});
|
||||
const withoutHeaders = tierListMakerPathWithState({
|
||||
state: makeState(),
|
||||
title: "",
|
||||
showTierHeaders: false,
|
||||
});
|
||||
|
||||
expect(withHeaders).not.toContain(
|
||||
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
|
||||
);
|
||||
expect(
|
||||
new URLSearchParams(withoutHeaders.split("?")[1]).get(
|
||||
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
|
||||
),
|
||||
).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierListItemId", () => {
|
||||
it("omits nth when it is not set", () => {
|
||||
expect(tierListItemId(splattershot)).toBe("main-weapon:40");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,54 @@
|
|||
import { compressToBase64, decompressFromBase64 } from "~/utils/compression";
|
||||
import { TIER_LIST_MAKER_URL } from "~/utils/urls";
|
||||
import {
|
||||
TIER_LIST_SEARCH_PARAM_NAMES,
|
||||
TIER_NAME_FONT_SIZE_BREAKPOINTS,
|
||||
TIER_NAME_FONT_SIZE_MIN,
|
||||
} from "./tier-list-maker-constants";
|
||||
import type { TierListItem, TierListState } from "./tier-list-maker-schemas";
|
||||
|
||||
export function tierListItemId(item: TierListItem) {
|
||||
return `${item.type}:${item.id}${item.nth ? `:${item.nth}` : ""}`;
|
||||
}
|
||||
|
||||
/** Compressed representation of the tier list, as stored in the page's search params. */
|
||||
export function serializeTierListState(state: TierListState) {
|
||||
return compress({
|
||||
tiers: state.tiers,
|
||||
tierItems: Array.from(state.tierItems.entries()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the tier list maker page that opens the given tier list as it was made,
|
||||
* used by the exported image's QR code.
|
||||
*/
|
||||
export function tierListMakerPathWithState({
|
||||
state,
|
||||
title,
|
||||
showTierHeaders,
|
||||
}: {
|
||||
state: TierListState;
|
||||
title: string;
|
||||
showTierHeaders: boolean;
|
||||
}) {
|
||||
const searchParams = new URLSearchParams({
|
||||
[TIER_LIST_SEARCH_PARAM_NAMES.STATE]: serializeTierListState(state),
|
||||
});
|
||||
|
||||
if (title) {
|
||||
searchParams.set(TIER_LIST_SEARCH_PARAM_NAMES.TITLE, title);
|
||||
}
|
||||
if (!showTierHeaders) {
|
||||
searchParams.set(
|
||||
TIER_LIST_SEARCH_PARAM_NAMES.SHOW_TIER_HEADERS,
|
||||
String(showTierHeaders),
|
||||
);
|
||||
}
|
||||
|
||||
return `${TIER_LIST_MAKER_URL}?${searchParams}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new tier list state with the given item appended to the end of the
|
||||
* specified tier. Used by the "click" placement mode. If the tier does not
|
||||
|
|
@ -52,10 +96,21 @@ export function getNextNthForItem(
|
|||
);
|
||||
}
|
||||
|
||||
export function compress<T>(obj: T) {
|
||||
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
|
||||
/**
|
||||
* Resolves the font size for a tier label so that longer tier names
|
||||
* shrink to fit inside the fixed-width label.
|
||||
*/
|
||||
export function tierNameFontSize(name: string) {
|
||||
const length = name.length;
|
||||
for (const breakpoint of TIER_NAME_FONT_SIZE_BREAKPOINTS) {
|
||||
if (length <= breakpoint.maxLength) {
|
||||
return breakpoint.fontSize;
|
||||
}
|
||||
}
|
||||
return TIER_NAME_FONT_SIZE_MIN;
|
||||
}
|
||||
|
||||
/** Reverses {@link serializeTierListState}, returning null if the input is not valid. */
|
||||
export function decompress<T>(compressed: string) {
|
||||
const json = decompressFromBase64(compressed);
|
||||
if (json === null) return null;
|
||||
|
|
@ -66,3 +121,7 @@ export function decompress<T>(compressed: string) {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function compress<T>(obj: T) {
|
||||
return compressToBase64(JSON.stringify(obj), { urlSafe: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -389,7 +389,8 @@ function ModelField({
|
|||
{(["light", "dark"] as const).map((theme) => (
|
||||
<div
|
||||
key={theme}
|
||||
className={clsx(styles.previewTheme, `${theme}-preview`)}
|
||||
className={styles.previewTheme}
|
||||
data-theme={theme}
|
||||
>
|
||||
<span className={styles.previewThemeLabel}>
|
||||
{t(`trophies:new.form.preview.${theme}`)}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,21 @@ export function findIdByIdentifier(identifier: string) {
|
|||
return userByIdentifierQuery(identifier).executeTakeFirst();
|
||||
}
|
||||
|
||||
/** Country codes of the given users keyed by user id, users without a country set absent. */
|
||||
export async function findCountriesByUserIds(userIds: number[]) {
|
||||
if (userIds.length === 0) return new Map<number, string>();
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("User")
|
||||
.select(["User.id", "User.country"])
|
||||
.where("User.id", "in", userIds)
|
||||
.where("User.country", "is not", null)
|
||||
.$narrowType<{ country: NotNull }>()
|
||||
.execute();
|
||||
|
||||
return new Map(rows.map((row) => [row.id, row.country]));
|
||||
}
|
||||
|
||||
export async function findBuildFieldsByIdentifier(identifier: string) {
|
||||
const row = await userByIdentifierQuery(identifier)
|
||||
.select(({ eb }) => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as SeasonSummary from "~/features/img-export/core/SeasonSummary";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import * as SkillRepository from "~/features/mmr/SkillRepository.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
|
||||
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { forbidden, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { resolveAvatarUrl } from "~/utils/urls";
|
||||
import {
|
||||
seasonSummaryGraphicSearchParamsSchema,
|
||||
userParamsSchema,
|
||||
} from "../user-page-schemas";
|
||||
|
||||
const BEST_SETS_COUNT = 3;
|
||||
/** The graphic shows fewer than this when it also has weapons to show */
|
||||
const TOP_MATES_COUNT = 6;
|
||||
|
||||
export type UserSeasonSummaryGraphicLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params, url }: LoaderFunctionArgs) => {
|
||||
const loggedInUser = requireUser();
|
||||
const { identifier } = userParamsSchema.parse(params);
|
||||
const parsedSearchParams = seasonSummaryGraphicSearchParamsSchema.safeParse(
|
||||
Object.fromEntries(url.searchParams),
|
||||
);
|
||||
if (!parsedSearchParams.success) {
|
||||
throw new Response(null, { status: 400 });
|
||||
}
|
||||
const { season } = parsedSearchParams.data;
|
||||
|
||||
const user = notFoundIfNullish(
|
||||
await UserRepository.findIdByIdentifier(identifier),
|
||||
);
|
||||
const seasonsParticipatedIn =
|
||||
await LeaderboardRepository.findSeasonsParticipatedInByUserId(user.id);
|
||||
const skill = (await userSkills(season)).userSkills[user.id];
|
||||
|
||||
if (
|
||||
!skill ||
|
||||
skill.approximate ||
|
||||
!SeasonSummary.canExportSeasonSummary({
|
||||
loggedInUser,
|
||||
profileUserId: user.id,
|
||||
season,
|
||||
seasonsParticipatedIn,
|
||||
hasCalculatedSkill: true,
|
||||
})
|
||||
) {
|
||||
throw forbidden();
|
||||
}
|
||||
|
||||
const setScores = await PlayerStatRepository.findSeasonSetScoresByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
});
|
||||
const setWinrate = await PlayerStatRepository.findSeasonSetWinrateByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
});
|
||||
const mapWinrate = await PlayerStatRepository.findSeasonMapWinrateByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
});
|
||||
|
||||
const soloRank = (
|
||||
await LeaderboardRepository.findUserSPLeaderboard(season)
|
||||
).find((entry) => entry.id === user.id)?.placementRank;
|
||||
const teamEntry = await findTeamEntry({ season, userId: user.id });
|
||||
|
||||
const mates = await PlayerStatRepository.findSeasonMatesEnemiesByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
type: "MATE",
|
||||
});
|
||||
const topMates = mates
|
||||
.toSorted((a, b) => b.setWins + b.setLosses - (a.setWins + a.setLosses))
|
||||
.slice(0, TOP_MATES_COUNT);
|
||||
|
||||
const countries = await UserRepository.findCountriesByUserIds([
|
||||
...(teamEntry?.entry.members.map((member) => member.id) ?? []),
|
||||
...topMates.map((mate) => mate.user.id),
|
||||
]);
|
||||
|
||||
const bestSets = await PlayerStatRepository.findSeasonBestSetsByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
limit: BEST_SETS_COUNT,
|
||||
});
|
||||
const bestRun = SeasonSummary.bestTournamentRun(
|
||||
(
|
||||
await PlayerStatRepository.findSeasonTournamentRunsByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
})
|
||||
).map((run) => ({
|
||||
...run,
|
||||
topEightAvgSp:
|
||||
typeof run.topEightAvgOrdinal === "number"
|
||||
? ordinalToSp(run.topEightAvgOrdinal)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
season,
|
||||
tier: skill.tier,
|
||||
sp: ordinalToSp(skill.ordinal),
|
||||
setsWon: setWinrate.wins,
|
||||
setsLost: setWinrate.losses,
|
||||
mapsWon: mapWinrate.wins,
|
||||
mapsLost: mapWinrate.losses,
|
||||
longestWinStreak: SeasonSummary.longestWinStreak(setScores),
|
||||
clutch: SeasonSummary.clutchRecord(setScores),
|
||||
soloRank,
|
||||
teamRank: teamEntry
|
||||
? {
|
||||
rank: teamEntry.rank,
|
||||
sp: teamEntry.entry.power,
|
||||
mates: teamEntry.entry.members
|
||||
.filter((member) => member.id !== user.id)
|
||||
.map((member) => ({
|
||||
name: member.username,
|
||||
countryCode: countries.get(member.id),
|
||||
})),
|
||||
team: teamEntry.entry.team
|
||||
? {
|
||||
name: teamEntry.entry.team.name,
|
||||
logoUrl: teamEntry.entry.team.avatarUrl ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
topMates: topMates.map((mate) => ({
|
||||
player: {
|
||||
name: mate.user.username,
|
||||
countryCode: countries.get(mate.user.id),
|
||||
},
|
||||
discordId: mate.user.discordId,
|
||||
avatarUrl: resolveAvatarUrl({
|
||||
customAvatarUrl: mate.user.customAvatarUrl,
|
||||
discordId: mate.user.discordId,
|
||||
discordAvatar: mate.user.discordAvatar,
|
||||
size: "sm",
|
||||
}),
|
||||
setsCount: mate.setWins + mate.setLosses,
|
||||
})),
|
||||
bestStage: SeasonSummary.bestStage(
|
||||
await PlayerStatRepository.findSeasonStagesByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
}),
|
||||
),
|
||||
spProgression: (
|
||||
await SkillRepository.findSeasonProgressionByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
})
|
||||
).map((point) => ({ date: point.date, sp: ordinalToSp(point.ordinal) })),
|
||||
activeDays: await SkillRepository.findSeasonActiveDaysByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
}),
|
||||
bestSets: bestSets.map((set) => ({
|
||||
opponentPlayers: set.opponentPlayers.map((player) => ({
|
||||
name: player.username,
|
||||
countryCode: player.country ?? undefined,
|
||||
})),
|
||||
ownScore: set.ownScore,
|
||||
opponentScore: set.opponentScore,
|
||||
opponentSp: ordinalToSp(set.avgOpponentOrdinal),
|
||||
context: set.tournamentName ?? "SendouQ",
|
||||
})),
|
||||
bestTournament: bestRun
|
||||
? {
|
||||
name: bestRun.name,
|
||||
logoUrl: bestRun.logoUrl,
|
||||
tier: bestRun.tier ?? undefined,
|
||||
placement: bestRun.placement,
|
||||
teamsCount: bestRun.teamsCount,
|
||||
}
|
||||
: undefined,
|
||||
topWeapons: SeasonSummary.topWeaponUsages(
|
||||
await ReportedWeaponRepository.findSeasonReportedWeaponsByUserId({
|
||||
userId: user.id,
|
||||
season,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
async function findTeamEntry({
|
||||
season,
|
||||
userId,
|
||||
}: {
|
||||
season: number;
|
||||
userId: number;
|
||||
}) {
|
||||
const hasUser = (entry: { members: Array<{ id: number }> }) =>
|
||||
entry.members.some((member) => member.id === userId);
|
||||
|
||||
const rankedEntry = (
|
||||
await LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser: true,
|
||||
})
|
||||
).find(hasUser);
|
||||
|
||||
if (rankedEntry)
|
||||
return { entry: rankedEntry, rank: rankedEntry.placementRank };
|
||||
|
||||
// rosters that only show up on the "all entries" leaderboard have no
|
||||
// placement comparable to the one shown on the main team leaderboard
|
||||
const unrankedEntry = (
|
||||
await LeaderboardRepository.findTeamLeaderboardBySeason({
|
||||
season,
|
||||
onlyOneEntryPerUser: false,
|
||||
})
|
||||
).find(hasUser);
|
||||
|
||||
if (!unrankedEntry) return undefined;
|
||||
|
||||
return { entry: unrankedEntry, rank: undefined };
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ import userStyles from "../user-page.module.css";
|
|||
import styles from "./u.$identifier.builds.module.css";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["weapons", "builds", "gear"],
|
||||
i18n: ["weapons", "builds", "gear", "analyzer"],
|
||||
};
|
||||
|
||||
type BuildFilter = "ALL" | "PUBLIC" | "PRIVATE" | MainWeaponId;
|
||||
|
|
@ -103,7 +103,13 @@ export default function UserBuildsPage() {
|
|||
{builds.length > 0 ? (
|
||||
<div className={styles.buildsContainer}>
|
||||
{builds.map((build) => (
|
||||
<BuildCard key={build.id} build={build} canEdit={isOwnPage} />
|
||||
<BuildCard
|
||||
key={build.id}
|
||||
build={build}
|
||||
owner={layoutData.user}
|
||||
showOwner={false}
|
||||
canEdit={isOwnPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export const handle: SendouRouteHandle = {
|
|||
"weapons",
|
||||
"gear",
|
||||
"game-badges",
|
||||
"analyzer",
|
||||
"trophies",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { loader } from "../loaders/u.$identifier.seasons.summary-graphic.server";
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import clsx from "clsx";
|
||||
import { HardDriveDownload } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Link,
|
||||
Outlet,
|
||||
type ShouldRevalidateFunction,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useLocation,
|
||||
useMatches,
|
||||
|
|
@ -29,6 +31,10 @@ import { TierImage } from "~/components/Image";
|
|||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
import { mainStyles } from "~/components/Main";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { ImageExportDialog } from "~/features/img-export/components/ImageExportDialog";
|
||||
import { SeasonSummaryGraphic } from "~/features/img-export/components/SeasonSummaryGraphic";
|
||||
import * as SeasonSummary from "~/features/img-export/core/SeasonSummary";
|
||||
import { TopTenPlayer } from "~/features/leaderboards/components/TopTenPlayer";
|
||||
import { playerTopTenPlacement } from "~/features/leaderboards/leaderboards-utils";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
|
|
@ -37,9 +43,11 @@ import invariant from "~/utils/invariant";
|
|||
import { isRevalidation } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
resolveAvatarUrl,
|
||||
sendouQMatchPage,
|
||||
TIERS_PAGE,
|
||||
userPage,
|
||||
userSeasonSummaryGraphicPage,
|
||||
userSeasonsPage,
|
||||
userSeasonsStatsPage,
|
||||
} from "~/utils/urls";
|
||||
|
|
@ -48,13 +56,14 @@ import {
|
|||
loader,
|
||||
type UserSeasonsPageLoaderData,
|
||||
} from "../loaders/u.$identifier.seasons.server";
|
||||
import type { UserSeasonSummaryGraphicLoaderData } from "../loaders/u.$identifier.seasons.summary-graphic.server";
|
||||
import type { UserPageLoaderData } from "../loaders/u.$identifier.server";
|
||||
import styles from "../user-page.module.css";
|
||||
|
||||
export { loader };
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["user"],
|
||||
i18n: ["user", "calendar"],
|
||||
};
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||
|
|
@ -103,10 +112,18 @@ export default function UserSeasonsLayout() {
|
|||
user={layoutData.user}
|
||||
backTo={userPage(layoutData.user)}
|
||||
/>
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
/>
|
||||
<div className="stack horizontal justify-between items-start">
|
||||
<SeasonHeader
|
||||
seasonViewed={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
/>
|
||||
<SeasonSummaryExport
|
||||
profileUser={layoutData.user}
|
||||
season={data.season}
|
||||
seasonsParticipatedIn={data.seasonsParticipatedIn}
|
||||
hasCalculatedSkill={Boolean(data.currentOrdinal)}
|
||||
/>
|
||||
</div>
|
||||
{data.currentOrdinal ? (
|
||||
<div className="stack md">
|
||||
<Rank
|
||||
|
|
@ -176,6 +193,121 @@ function SeasonNav({
|
|||
);
|
||||
}
|
||||
|
||||
function SeasonSummaryExport({
|
||||
profileUser,
|
||||
season,
|
||||
seasonsParticipatedIn,
|
||||
hasCalculatedSkill,
|
||||
}: {
|
||||
profileUser: UserPageLoaderData["user"];
|
||||
season: number;
|
||||
seasonsParticipatedIn: number[];
|
||||
hasCalculatedSkill: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const loggedInUser = useUser();
|
||||
|
||||
if (
|
||||
!loggedInUser ||
|
||||
loggedInUser.id !== profileUser.id ||
|
||||
!hasCalculatedSkill ||
|
||||
!SeasonSummary.isSeasonFinished(season)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canExport = SeasonSummary.canExportSeasonSummary({
|
||||
loggedInUser,
|
||||
profileUserId: profileUser.id,
|
||||
season,
|
||||
seasonsParticipatedIn,
|
||||
hasCalculatedSkill,
|
||||
});
|
||||
|
||||
if (!canExport) {
|
||||
return (
|
||||
<SendouPopover
|
||||
trigger={
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<HardDriveDownload />}
|
||||
>
|
||||
{t("user:seasons.summary.export")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{t("user:seasons.summary.export.supporterPerk")}
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SeasonSummaryExportDialog
|
||||
key={season}
|
||||
profileUser={profileUser}
|
||||
season={season}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonSummaryExportDialog({
|
||||
profileUser,
|
||||
season,
|
||||
}: {
|
||||
profileUser: UserPageLoaderData["user"];
|
||||
season: number;
|
||||
}) {
|
||||
const { t } = useTranslation(["user"]);
|
||||
const fetcher = useFetcher<UserSeasonSummaryGraphicLoaderData>();
|
||||
|
||||
const handleOpen = () => {
|
||||
if (fetcher.state === "idle" && !fetcher.data) {
|
||||
fetcher.load(userSeasonSummaryGraphicPage({ user: profileUser, season }));
|
||||
}
|
||||
};
|
||||
|
||||
const data = fetcher.data;
|
||||
|
||||
return (
|
||||
<ImageExportDialog
|
||||
trigger={
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="outlined"
|
||||
icon={<HardDriveDownload />}
|
||||
onPress={handleOpen}
|
||||
>
|
||||
{t("user:seasons.summary.export")}
|
||||
</SendouButton>
|
||||
}
|
||||
heading={t("user:seasons.summary.export")}
|
||||
filename={`season-${season}-summary`}
|
||||
qrCodePath={userSeasonsPage({ user: profileUser, season })}
|
||||
>
|
||||
{data ? (
|
||||
<SeasonSummaryGraphic
|
||||
user={{
|
||||
name: profileUser.username,
|
||||
discordId: profileUser.discordId,
|
||||
customUrl: profileUser.customUrl ?? undefined,
|
||||
countryCode: profileUser.country ?? undefined,
|
||||
avatarUrl: resolveAvatarUrl({
|
||||
customAvatarUrl: profileUser.customAvatarUrl,
|
||||
discordId: profileUser.discordId,
|
||||
discordAvatar: profileUser.discordAvatar,
|
||||
size: "lg",
|
||||
}),
|
||||
}}
|
||||
season={data.season}
|
||||
seasonDateRange={Seasons.nthToDateRange(data.season)}
|
||||
stats={data}
|
||||
/>
|
||||
) : null}
|
||||
</ImageExportDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SeasonHeader({
|
||||
seasonViewed,
|
||||
seasonsParticipatedIn,
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ export const seasonsSearchParamsSchema = z.object({
|
|||
.refine((nth) => !nth || Seasons.allStarted(new Date()).includes(nth)),
|
||||
});
|
||||
|
||||
export const seasonSummaryGraphicSearchParamsSchema = z.object({
|
||||
season: z.coerce
|
||||
.number()
|
||||
.refine((nth) => Seasons.allStarted(new Date()).includes(nth)),
|
||||
});
|
||||
|
||||
const SENS_ITEMS = [
|
||||
-50, -45, -40, -35, -30, -25, -20, -15, -10, -5, 0, 5, 10, 15, 20, 25, 30, 35,
|
||||
40, 45, 50,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export default [
|
|||
"edit-widgets",
|
||||
"features/user-page/routes/u.$identifier.edit-widgets.tsx",
|
||||
),
|
||||
route(
|
||||
"seasons/summary-graphic",
|
||||
"features/user-page/routes/u.$identifier.seasons.summary-graphic.ts",
|
||||
),
|
||||
route("seasons", "features/user-page/routes/u.$identifier.seasons.tsx", [
|
||||
index("features/user-page/routes/u.$identifier.seasons.index.tsx"),
|
||||
route(
|
||||
|
|
|
|||
|
|
@ -15,8 +15,13 @@ These should not be consumed directly, only used as a base for other vars!
|
|||
|
||||
The hue and chroma values should not be edited manually, instead
|
||||
use oklch-gamut.ts to generate valid color palettes!
|
||||
|
||||
A subtree can force a color scheme regardless of the html class with
|
||||
[data-theme="dark"|"light"] and opt out of an active custom theme with
|
||||
[data-default-theme] (e.g. image export previews)
|
||||
*/
|
||||
html {
|
||||
html,
|
||||
[data-default-theme] {
|
||||
--_base-h: 268;
|
||||
--_base-c-0: 0;
|
||||
--_base-c-1: 0.02357547860966049;
|
||||
|
|
@ -81,7 +86,7 @@ Any changes here NEED to be reflected in oklch-gamut.ts as well
|
|||
*/
|
||||
html.dark,
|
||||
html.dark [data-custom-theme],
|
||||
.dark-preview {
|
||||
[data-theme="dark"] {
|
||||
--color-base-0: oklch(100% var(--_base-c-0) var(--_base-h));
|
||||
--color-base-1: oklch(95% var(--_base-c-1) var(--_base-h));
|
||||
--color-base-2: oklch(90% var(--_base-c-2) var(--_base-h));
|
||||
|
|
@ -133,7 +138,7 @@ html.dark [data-custom-theme],
|
|||
|
||||
html.light,
|
||||
html.light [data-custom-theme],
|
||||
.light-preview {
|
||||
[data-theme="light"] {
|
||||
--color-base-0: oklch(17% var(--_base-c-7) var(--_base-h));
|
||||
--color-base-1: oklch(25% var(--_base-c-6) var(--_base-h));
|
||||
--color-base-2: oklch(32% var(--_base-c-5) var(--_base-h));
|
||||
|
|
@ -187,10 +192,9 @@ html.light [data-custom-theme],
|
|||
These are the vars you mainly want to use
|
||||
*/
|
||||
html,
|
||||
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] re-declares theme vars for card subtrees; different properties than the html.dark/light blocks so no real override */
|
||||
/* biome-ignore lint/style/noDescendingSpecificity: [data-custom-theme] and [data-theme] re-declare theme vars for subtrees; different properties than the html.dark/light blocks so no real override */
|
||||
[data-custom-theme],
|
||||
.dark-preview,
|
||||
.light-preview {
|
||||
[data-theme] {
|
||||
--color-text: var(--color-base-0);
|
||||
--color-text-high: var(--color-base-3);
|
||||
--color-text-inverse: var(--color-base-7);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,29 @@ export const discordAvatarUrl = ({
|
|||
discordAvatar
|
||||
}.webp${size === "lg" ? "?size=240" : "?size=80"}`;
|
||||
|
||||
/**
|
||||
* Resolves the avatar image url of an user, preferring their custom avatar over
|
||||
* the Discord one. Returns undefined if the user has neither.
|
||||
*/
|
||||
export const resolveAvatarUrl = ({
|
||||
customAvatarUrl,
|
||||
discordId,
|
||||
discordAvatar,
|
||||
size,
|
||||
}: {
|
||||
customAvatarUrl?: string | null;
|
||||
discordId: string;
|
||||
discordAvatar?: string | null;
|
||||
size: "lg" | "sm";
|
||||
}) => {
|
||||
if (customAvatarUrl) return customAvatarUrl;
|
||||
if (discordAvatar) {
|
||||
return discordAvatarUrl({ discordId, discordAvatar, size });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
|
||||
|
||||
export const BADGES_DOC_LINK =
|
||||
|
|
@ -187,6 +210,13 @@ export const userSeasonsPage = ({
|
|||
`${userPage(user)}/seasons${
|
||||
typeof season === "number" ? `?season=${season}` : ""
|
||||
}`;
|
||||
export const userSeasonSummaryGraphicPage = ({
|
||||
user,
|
||||
season,
|
||||
}: {
|
||||
user: UserLinkArgs;
|
||||
season: number;
|
||||
}) => `${userPage(user)}/seasons/summary-graphic?season=${season}`;
|
||||
export const userSeasonsStatsPage = ({
|
||||
user,
|
||||
season,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export async function loadFactories(parallelIndex: number) {
|
|||
|
||||
return {
|
||||
backdate: (await import("~/db/seed/core/backdate")).backdate,
|
||||
reseason: (await import("~/db/seed/core/reseason")).reseason,
|
||||
ApiTokenFactory: await import("~/db/seed/factories/ApiTokenFactory"),
|
||||
ArtFactory: await import("~/db/seed/factories/ArtFactory"),
|
||||
AssociationFactory: await import("~/db/seed/factories/AssociationFactory"),
|
||||
|
|
|
|||
43
e2e/pages/user/user-seasons-page.ts
Normal file
43
e2e/pages/user/user-seasons-page.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { userSeasonsPage } from "~/utils/urls";
|
||||
import { navigate } from "../../helpers/playwright";
|
||||
|
||||
/** A user profile's `/seasons` page, including the season summary image export. */
|
||||
export class UserSeasonsPage {
|
||||
private readonly page: Page;
|
||||
readonly locators;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.locators = {
|
||||
exportImageButton: page.getByRole("button", { name: "Export image" }),
|
||||
exportDialog: page.getByRole("dialog"),
|
||||
downloadButton: page.getByRole("button", { name: "Download" }),
|
||||
supporterPerkExplanation: page.getByText(/supporter perk/),
|
||||
};
|
||||
}
|
||||
|
||||
async goto(discordId: string, season?: number) {
|
||||
await navigate({
|
||||
page: this.page,
|
||||
url: userSeasonsPage({ user: { discordId }, season }),
|
||||
});
|
||||
}
|
||||
|
||||
async openExportDialog() {
|
||||
await this.locators.exportImageButton.click();
|
||||
}
|
||||
|
||||
exportDialogText(content: string) {
|
||||
return this.locators.exportDialog.getByText(content);
|
||||
}
|
||||
|
||||
async downloadExportedImage() {
|
||||
const downloadPromise = this.page.waitForEvent("download");
|
||||
await this.locators.exportDialog
|
||||
.getByRole("button", { name: "Download" })
|
||||
.click();
|
||||
|
||||
return downloadPromise;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,18 @@
|
|||
import { addDays } from "date-fns";
|
||||
import { NZAP_TEST_DISCORD_ID, NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants";
|
||||
import type { Factories } from "./helpers/factories";
|
||||
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
|
||||
import { SettingsPage } from "./pages/settings/settings-page";
|
||||
import { UserEditProfilePage } from "./pages/user/user-edit-profile-page";
|
||||
import { UserPage } from "./pages/user/user-page";
|
||||
import { UserSeasonsPage } from "./pages/user/user-seasons-page";
|
||||
|
||||
/** The only season the e2e seasons list has finished, i.e. the exportable one. */
|
||||
const FINISHED_SEASON = 0;
|
||||
|
||||
test.describe("User page", () => {
|
||||
test("uses badge pagination", async ({ page, factories }) => {
|
||||
|
|
@ -147,6 +156,43 @@ test.describe("User page", () => {
|
|||
await expect(settings.hasCustomTheme()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("exports season summary image as a supporter", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await factories.UserFactory.grant(ADMIN_ID, { patronTier: 2 });
|
||||
await playFinishedSeason(factories, ADMIN_ID);
|
||||
|
||||
await impersonate(page);
|
||||
|
||||
const seasonsPage = new UserSeasonsPage(page);
|
||||
await seasonsPage.goto(ADMIN_DISCORD_ID);
|
||||
await seasonsPage.openExportDialog();
|
||||
|
||||
await expect(seasonsPage.exportDialogText("Best win streak")).toBeVisible();
|
||||
|
||||
const download = await seasonsPage.downloadExportedImage();
|
||||
expect(download.suggestedFilename()).toBe(
|
||||
`season-${FINISHED_SEASON}-summary.png`,
|
||||
);
|
||||
});
|
||||
|
||||
test("shows supporter perk explanation instead of exporting for non-supporter mid-season", async ({
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
await playFinishedSeason(factories, NZAP_TEST_ID);
|
||||
|
||||
await impersonate(page, NZAP_TEST_ID);
|
||||
|
||||
const seasonsPage = new UserSeasonsPage(page);
|
||||
await seasonsPage.goto(NZAP_TEST_DISCORD_ID);
|
||||
await seasonsPage.openExportDialog();
|
||||
|
||||
await expect(seasonsPage.locators.supporterPerkExplanation).toBeVisible();
|
||||
await isNotVisible(seasonsPage.locators.downloadButton);
|
||||
});
|
||||
|
||||
test("edits weapon pool", async ({ page, factories }) => {
|
||||
await factories.UserFactory.grant(ADMIN_ID, {
|
||||
weapons: ([200, 1100, 2000, 4000] as const).map((weaponSplId) => ({
|
||||
|
|
@ -175,3 +221,32 @@ test.describe("User page", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Plays the user through a whole season of SendouQ, ending it with a calculated
|
||||
* (i.e. non-approximate) skill. The matches are spread over the days of the only
|
||||
* finished season so that the season summary has something to show.
|
||||
*/
|
||||
async function playFinishedSeason(factories: Factories, userId: number) {
|
||||
const mates = await factories.UserFactory.createMany(FULL_GROUP_SIZE - 1);
|
||||
const enemies = await factories.UserFactory.createMany(FULL_GROUP_SIZE);
|
||||
|
||||
const ownGroup = [userId, ...mates.map((mate) => mate.id)];
|
||||
const opposingGroup = enemies.map((enemy) => enemy.id);
|
||||
const { starts } = Seasons.nthToDateRange(FINISHED_SEASON);
|
||||
|
||||
for (let index = 0; index < MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; index++) {
|
||||
// alpha wins every map, so which side the user is on decides the set
|
||||
const userWon = index % 3 !== 0;
|
||||
|
||||
await factories.SQMatchFactory.create(
|
||||
{
|
||||
alphaUserIds: userWon ? ownGroup : opposingGroup,
|
||||
bravoUserIds: userWon ? opposingGroup : ownGroup,
|
||||
},
|
||||
{ isConcluded: true, createdAt: addDays(starts, index) },
|
||||
);
|
||||
}
|
||||
|
||||
await factories.reseason(FINISHED_SEASON);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Bliv medlem",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Tilpas farver",
|
||||
"custom.colors.bg": "Baggrund",
|
||||
"custom.colors.bg-darker": "Mørkere baggrund",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "Der p.t. er ingen direkte udsendelser af denne turnering",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Vundne sæt",
|
||||
"team.mapWins": "Sejr på baner",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@
|
|||
"seasons.noReportedWeapons": "Der er endnu ikke blevet indrapporteret nogle våben",
|
||||
"seasons.clickARow": "Tryk på en række for at se våbenbrugsstatistikker.",
|
||||
"seasons.loading": "indlæser...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "Ændr sortering",
|
||||
"builds.sorting.header": "Ændr sæt-sortering",
|
||||
"builds.sorting.backToDefaults": "Nulstil visning",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Beitreten",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Eigene Farben",
|
||||
"custom.colors.bg": "Hintergrund",
|
||||
"custom.colors.bg-darker": "Hintergrund dunkler",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "Aktuell sind keine Livestreams von diesem Turnier verfügbar",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Set-Siege",
|
||||
"team.mapWins": "Arenen-Siege",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "Outlined",
|
||||
"actions.noOutline": "No outline",
|
||||
"actions.join": "Join",
|
||||
"imageExport.export": "Export image",
|
||||
"imageExport.download": "Download",
|
||||
"imageExport.theme.light": "Light",
|
||||
"imageExport.theme.dark": "Dark",
|
||||
"imageExport.theme.lightCustom": "Light (custom)",
|
||||
"imageExport.theme.darkCustom": "Dark (custom)",
|
||||
"imageExport.qrCode": "QR code",
|
||||
"imageExport.buildTitle": "Build title",
|
||||
"imageExport.abilityPoints": "Ability points",
|
||||
"imageExport.abilityChunks": "Ability chunks",
|
||||
"host": "Host",
|
||||
"seed": "Seed {{number}}",
|
||||
"actions.nevermind": "Nevermind",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "Log-in via Discord bot",
|
||||
"support.perk.useBotToLogIn.extra": "Request a log-in link from the Lohi bot as an alternative to the normal website log-in",
|
||||
"support.perk.earlyAccess": "Occasional early access to features",
|
||||
"support.perk.seasonSummaryImage": "Export any season's summary image",
|
||||
"support.perk.seasonSummaryImage.extra": "Export a summary image of any SendouQ season you played. Everyone can export the latest finished season's image during the off-season.",
|
||||
"custom.colors.title": "Custom colors",
|
||||
"custom.colors.bg": "Background",
|
||||
"custom.colors.bg-darker": "Background darker",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "Add tier",
|
||||
"download": "Download (.png)",
|
||||
"mainWeapons": "Main Weapons",
|
||||
"subWeapons": "Sub Weapons",
|
||||
"specialWeapons": "Special Weapons",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "Are you sure you want to reset the tier list? This will remove all items and restore default tiers.",
|
||||
"noDuplicates": "No duplicates",
|
||||
"showTierHeaders": "Show tier headers",
|
||||
"titlePlaceholder": "Click to add title...",
|
||||
"title": "Title",
|
||||
"showUsername": "Show username",
|
||||
"by": "Made by",
|
||||
"custom": "Custom"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@
|
|||
"finalize.receivingTeam.label": "Receiving team",
|
||||
"finalize.receivingTeam.placeholder": "Select team…",
|
||||
"streams.none": "No live streams of this tournament available currently",
|
||||
"run.sets": "Sets",
|
||||
"run.maps": "Maps",
|
||||
"run.qualifiedFor": "Advanced to {{bracket}}",
|
||||
"run.firstTitle": "First title",
|
||||
"run.latestTitle": "Latest title",
|
||||
"run.seriesTitles": "Series titles",
|
||||
"team.setWins": "Set wins",
|
||||
"team.mapWins": "Map wins",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@
|
|||
"seasons.noReportedWeapons": "No reported weapons yet",
|
||||
"seasons.clickARow": "Click a row to see weapon usage stats",
|
||||
"seasons.loading": "Loading...",
|
||||
"seasons.summary.sets": "Sets",
|
||||
"seasons.summary.maps": "Maps",
|
||||
"seasons.summary.winStreak": "Best win streak",
|
||||
"seasons.summary.soloRank": "Solo rank",
|
||||
"seasons.summary.teamRank": "Team rank",
|
||||
"seasons.summary.activity": "Activity",
|
||||
"seasons.summary.activity.tournament": "Tournament",
|
||||
"seasons.summary.activity.both": "Both",
|
||||
"seasons.summary.bestStage": "Best stage",
|
||||
"seasons.summary.topWeapons": "Top weapons",
|
||||
"seasons.summary.topMates": "Top teammates",
|
||||
"seasons.summary.clutch": "Clutch",
|
||||
"seasons.summary.bestWins": "Best wins",
|
||||
"seasons.summary.bestTournament": "Best tournament",
|
||||
"seasons.summary.opponentSp": "Opponent SP",
|
||||
"seasons.summary.count.sets_one": "{{count}} set",
|
||||
"seasons.summary.count.sets_other": "{{count}} sets",
|
||||
"seasons.summary.count.maps_one": "{{count}} map",
|
||||
"seasons.summary.count.maps_other": "{{count}} maps",
|
||||
"seasons.summary.export": "Export image",
|
||||
"seasons.summary.export.supporterPerk": "Exporting this season's summary image is a supporter perk. Everyone can export the latest finished season's image during the off-season.",
|
||||
"builds.sorting.changeButton": "Change sorting",
|
||||
"builds.sorting.header": "Change build sorting",
|
||||
"builds.sorting.backToDefaults": "Back to defaults",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "Con borde",
|
||||
"actions.noOutline": "Sin borde",
|
||||
"actions.join": "Unirse",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "Olvídalo",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "Iniciar sesión con el bot de Discord",
|
||||
"support.perk.useBotToLogIn.extra": "Solicita un enlace de inicio de sesión al bot Lohi como alternativa a iniciar sesión desde la web.",
|
||||
"support.perk.earlyAccess": "Acceso anticipado a nuevas funciones",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Colores personalizados",
|
||||
"custom.colors.bg": "Fondo",
|
||||
"custom.colors.bg-darker": "Fondo más oscuro",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "Añadir tier",
|
||||
"download": "Descargar (.png)",
|
||||
"mainWeapons": "Armas principales",
|
||||
"subWeapons": "Armas secundarias",
|
||||
"specialWeapons": "Armas especiales",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "¿Seguro que quieres reiniciar la tier list? Esto eliminará todos los elementos y restaurará las tiers por defecto.",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "Mostrar encabezados de las tiers",
|
||||
"titlePlaceholder": "Haz clic para añadir un título...",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "Creado por",
|
||||
"custom": "Personalizado"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "Equipo receptor",
|
||||
"finalize.receivingTeam.placeholder": "Seleccionar equipo...",
|
||||
"streams.none": "No hay streams disponibles para este torneo al momento",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Victorias de sets",
|
||||
"team.mapWins": "Victorias de mapas",
|
||||
"team.seed": "Colocado",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "No se han informado armas",
|
||||
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
|
||||
"seasons.loading": "Cargando...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "Cambiar orden",
|
||||
"builds.sorting.header": "Cambiar orden de las builds",
|
||||
"builds.sorting.backToDefaults": "Volver por defecto",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Unirse",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "Cancelar",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Colores personalizados",
|
||||
"custom.colors.bg": "Fondo",
|
||||
"custom.colors.bg-darker": "Fondo más oscuro",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "No hay streams disponibles para este torneo al momento",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Victorias de sets",
|
||||
"team.mapWins": "Victorias de juegos",
|
||||
"team.seed": "Colocado",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "No se han informado armas",
|
||||
"seasons.clickARow": "Haga clic en una fila para ver estadísticas de armas",
|
||||
"seasons.loading": "Cargando...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "Cambiar ordenación",
|
||||
"builds.sorting.header": "Cambiar ordenación de builds",
|
||||
"builds.sorting.backToDefaults": "Volver a los valores predeterminados",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Joindre",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Couleurs personalisées",
|
||||
"custom.colors.bg": "Arrière-plan",
|
||||
"custom.colors.bg-darker": "Arrière-plan sombre",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Set wins",
|
||||
"team.mapWins": "Map wins",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "Outlined",
|
||||
"actions.noOutline": "No outline",
|
||||
"actions.join": "Joindre",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "Laisser tomber",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "Se connecter via le bot Discord",
|
||||
"support.perk.useBotToLogIn.extra": "Demander un lien de connexion au bot Lohi comme alternative à la connexion normale au site Web",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Couleurs personalisées",
|
||||
"custom.colors.bg": "Arrière-plan",
|
||||
"custom.colors.bg-darker": "Arrière-plan sombre",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "Aucune diffusion en direct de ce tournoi n'est disponible actuellement",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Set gagné",
|
||||
"team.mapWins": "Map gagnée",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "Aucune arme a été reporté",
|
||||
"seasons.clickARow": "Cliquez sur une ligne pour voir les statistiques d'utilisation des armes",
|
||||
"seasons.loading": "Chargement...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "Changer le tri",
|
||||
"builds.sorting.header": "Modifier le tri des builds",
|
||||
"builds.sorting.backToDefaults": "Revenir par défaut",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "הצטרפות",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "צבעים מותאמים אישית",
|
||||
"custom.colors.bg": "רקע",
|
||||
"custom.colors.bg-darker": "רקע חשוך",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "אין שידורים חיים לטורניר זה כרגע",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "נצחונות סטים",
|
||||
"team.mapWins": "נצחונות במפות",
|
||||
"team.seed": "דירוג",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_two": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_two": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "Contornato",
|
||||
"actions.noOutline": "Nessun contorno",
|
||||
"actions.join": "Entra",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "Non importa",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Colori personalizzati",
|
||||
"custom.colors.bg": "Sfondo",
|
||||
"custom.colors.bg-darker": "Sfondo più scuro",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "Nessuna livestream di questo torneo disponibile al momento",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "Set vinti",
|
||||
"team.mapWins": "Mappe vinte",
|
||||
"team.seed": "Seed",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,29 @@
|
|||
"seasons.noReportedWeapons": "Nessun'arma riportata",
|
||||
"seasons.clickARow": "Clicca una riga per visualizzare le statistiche d'uso delle armi",
|
||||
"seasons.loading": "Caricamento...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "Cambia ordinamento",
|
||||
"builds.sorting.header": "Cambia ordinamento build",
|
||||
"builds.sorting.backToDefaults": "Torna al default",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "アウトラインあり",
|
||||
"actions.noOutline": "アウトラインなし",
|
||||
"actions.join": "参加する",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "部屋を建てる",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "Discord ボットを使用してログイン",
|
||||
"support.perk.useBotToLogIn.extra": "Lohi ボットから直接ログインできる URL を貰える",
|
||||
"support.perk.earlyAccess": "たまに新機能の事前お試し",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "カスタムカラー",
|
||||
"custom.colors.bg": "背景",
|
||||
"custom.colors.bg-darker": "背景 暗め",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "現在このトーナメントのストリーミングはありません",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "勝利セット",
|
||||
"team.mapWins": "勝利マップ",
|
||||
"team.seed": "シード",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,23 @@
|
|||
"seasons.noReportedWeapons": "報告された武器がありません",
|
||||
"seasons.clickARow": "武器の使用統計を見るには行を選択してください",
|
||||
"seasons.loading": "読み込み中...",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "並べ替え変更",
|
||||
"builds.sorting.header": "ギアの並べ替えを変更",
|
||||
"builds.sorting.backToDefaults": "デフォルトに戻す",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "참여하기",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "",
|
||||
"custom.colors.bg": "",
|
||||
"custom.colors.bg-darker": "",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,6 +136,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "",
|
||||
"team.mapWins": "",
|
||||
"team.seed": "",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,23 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "",
|
||||
"custom.colors.bg": "",
|
||||
"custom.colors.bg-darker": "",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "",
|
||||
"team.mapWins": "",
|
||||
"team.seed": "",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
|
|
@ -149,6 +149,16 @@
|
|||
"actions.outlined": "",
|
||||
"actions.noOutline": "",
|
||||
"actions.join": "Dołącz",
|
||||
"imageExport.export": "",
|
||||
"imageExport.download": "",
|
||||
"imageExport.theme.light": "",
|
||||
"imageExport.theme.dark": "",
|
||||
"imageExport.theme.lightCustom": "",
|
||||
"imageExport.theme.darkCustom": "",
|
||||
"imageExport.qrCode": "",
|
||||
"imageExport.buildTitle": "",
|
||||
"imageExport.abilityPoints": "",
|
||||
"imageExport.abilityChunks": "",
|
||||
"host": "",
|
||||
"seed": "",
|
||||
"actions.nevermind": "",
|
||||
|
|
@ -287,6 +297,8 @@
|
|||
"support.perk.useBotToLogIn": "",
|
||||
"support.perk.useBotToLogIn.extra": "",
|
||||
"support.perk.earlyAccess": "",
|
||||
"support.perk.seasonSummaryImage": "",
|
||||
"support.perk.seasonSummaryImage.extra": "",
|
||||
"custom.colors.title": "Niestandardowe kolory",
|
||||
"custom.colors.bg": "Tło",
|
||||
"custom.colors.bg-darker": "Ciemniejsze tło",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"addTier": "",
|
||||
"download": "",
|
||||
"mainWeapons": "",
|
||||
"subWeapons": "",
|
||||
"specialWeapons": "",
|
||||
|
|
@ -18,7 +17,8 @@
|
|||
"resetConfirmation": "",
|
||||
"noDuplicates": "",
|
||||
"showTierHeaders": "",
|
||||
"titlePlaceholder": "",
|
||||
"title": "",
|
||||
"showUsername": "",
|
||||
"by": "",
|
||||
"custom": ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,12 @@
|
|||
"finalize.receivingTeam.label": "",
|
||||
"finalize.receivingTeam.placeholder": "",
|
||||
"streams.none": "",
|
||||
"run.sets": "",
|
||||
"run.maps": "",
|
||||
"run.qualifiedFor": "",
|
||||
"run.firstTitle": "",
|
||||
"run.latestTitle": "",
|
||||
"run.seriesTitles": "",
|
||||
"team.setWins": "",
|
||||
"team.mapWins": "",
|
||||
"team.seed": "",
|
||||
|
|
|
|||
|
|
@ -186,6 +186,31 @@
|
|||
"seasons.noReportedWeapons": "",
|
||||
"seasons.clickARow": "",
|
||||
"seasons.loading": "",
|
||||
"seasons.summary.sets": "",
|
||||
"seasons.summary.maps": "",
|
||||
"seasons.summary.winStreak": "",
|
||||
"seasons.summary.soloRank": "",
|
||||
"seasons.summary.teamRank": "",
|
||||
"seasons.summary.activity": "",
|
||||
"seasons.summary.activity.tournament": "",
|
||||
"seasons.summary.activity.both": "",
|
||||
"seasons.summary.bestStage": "",
|
||||
"seasons.summary.topWeapons": "",
|
||||
"seasons.summary.topMates": "",
|
||||
"seasons.summary.clutch": "",
|
||||
"seasons.summary.bestWins": "",
|
||||
"seasons.summary.bestTournament": "",
|
||||
"seasons.summary.opponentSp": "",
|
||||
"seasons.summary.count.sets_one": "",
|
||||
"seasons.summary.count.sets_few": "",
|
||||
"seasons.summary.count.sets_many": "",
|
||||
"seasons.summary.count.sets_other": "",
|
||||
"seasons.summary.count.maps_one": "",
|
||||
"seasons.summary.count.maps_few": "",
|
||||
"seasons.summary.count.maps_many": "",
|
||||
"seasons.summary.count.maps_other": "",
|
||||
"seasons.summary.export": "",
|
||||
"seasons.summary.export.supporterPerk": "",
|
||||
"builds.sorting.changeButton": "",
|
||||
"builds.sorting.header": "",
|
||||
"builds.sorting.backToDefaults": "",
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user