This commit is contained in:
Kalle
2026-05-30 07:02:31 +03:00
parent 3b0bd509cd
commit e957713d1e
41 changed files with 1829 additions and 623 deletions

View File

@@ -78,6 +78,7 @@
- before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json)
- add only English translation and use `pnpm run i18n:sync` to initialize other jsons with empty string ready for translators
- when using namespace e.g. `const { t } = useTranslation("settings"]);` it needs to be defined in the `handle` for that route e.g. `export const handle: SendouRouteHandle = { i18n: ["settings"], ... }`. Certain namespaces are always included and you don't have to worry about those: "common", "forms", "game-misc", "weapons", "front", "friends"
- if changing translation key names make sure to port over any already translated values for non-english languages if the english language is unchanged
## Commit messages

View File

@@ -6926,6 +6926,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
series: [],
members: [
{
userId: 405,

View File

@@ -2029,6 +2029,7 @@ export const SWIM_OR_SINK_167 = (
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
series: [],
members: [
{
userId: 405,

View File

@@ -93,6 +93,16 @@ export async function findById(id: number) {
"TournamentOrganization.id",
),
).as("members"),
jsonArrayFrom(
innerEb
.selectFrom("TournamentOrganizationSeries")
.select("TournamentOrganizationSeries.name")
.whereRef(
"TournamentOrganizationSeries.organizationId",
"=",
"TournamentOrganization.id",
),
).as("series"),
])
.whereRef(
"TournamentOrganization.id",

View File

@@ -0,0 +1,59 @@
.wrapper {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: stretch;
gap: var(--s-8);
width: fit-content;
max-width: 100%;
margin-inline: auto;
}
.column {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.divider {
min-width: 2px;
background-color: var(--color-border-high);
border-radius: var(--radius-full);
flex-shrink: 0;
}
.card {
display: grid;
grid-template-columns: minmax(0, 6rem) minmax(0, max-content);
align-items: center;
gap: var(--s-3);
}
.label {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
text-transform: uppercase;
}
.value {
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--color-text);
min-width: 0;
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
/** xxx: divider gone and horizontal stack at the same time */
@media (max-width: 480px) {
.wrapper {
grid-template-columns: auto;
gap: var(--s-3);
}
.divider {
display: none;
}
}

View File

@@ -0,0 +1,41 @@
import type * as React from "react";
import styles from "./FactCard.module.css";
export interface FactCardItem {
label: string;
value: React.ReactNode;
}
export function FactCardGrid({ facts }: { facts: FactCardItem[] }) {
const leftFacts = facts.filter((_, i) => i % 2 === 0);
const rightFacts = facts.filter((_, i) => i % 2 === 1);
return (
<div className={styles.wrapper}>
<div className={styles.column}>
{leftFacts.map((fact) => (
<Card key={fact.label} {...fact} />
))}
</div>
{rightFacts.length > 0 ? (
<>
<div className={styles.divider} aria-hidden="true" />
<div className={styles.column}>
{rightFacts.map((fact) => (
<Card key={fact.label} {...fact} />
))}
</div>
</>
) : null}
</div>
);
}
function Card({ label, value }: FactCardItem) {
return (
<div className={styles.card}>
<div className={styles.label}>{label}</div>
<div className={styles.value}>{value}</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { ClipboardCheck, UserPlus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { tournamentRegisterPage, tournamentSubsPage } from "~/utils/urls";
export function RegistrationActions({
tournament,
}: {
tournament: Tournament;
}) {
const { t } = useTranslation(["tournament"]);
if (!tournament.registrationOpen) return null;
return (
<div className="stack horizontal sm justify-center items-center">
<LinkButton
to={tournamentRegisterPage(tournament.ctx.id)}
size="big"
icon={<ClipboardCheck />}
testId="register-cta"
>
{t("tournament:registerNow")}
</LinkButton>
{tournament.lfgEnabled ? (
<LinkButton
to={tournamentSubsPage(tournament.ctx.id)}
size="big"
variant="outlined"
icon={<UserPlus />}
>
{t("tournament:findTeam")}
</LinkButton>
) : null}
</div>
);
}

View File

@@ -0,0 +1,92 @@
.header {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-4);
text-align: center;
container-type: inline-size;
}
.identity {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-3);
}
.titleBlock {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-2);
}
@container (min-width: 448px) {
.identity {
flex-direction: row;
gap: var(--s-6);
}
}
.logo {
border-radius: var(--radius-avatar);
}
.nameBlock {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--s-1);
width: max-content;
max-width: 100%;
}
.name {
font-size: var(--font-xl);
font-weight: var(--weight-bold);
margin: 0;
text-wrap: balance;
text-align: center;
line-height: 0.9;
}
.subtext {
display: flex;
align-items: center;
gap: var(--s-2);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--color-text-high);
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 2px solid var(--color-text-high);
}
}
.organizer {
display: inline-flex;
align-items: center;
gap: var(--s-2);
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
}
.dates {
display: flex;
flex-direction: column;
gap: var(--s-1);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
color: var(--color-text-high);
}
.actions {
display: flex;
gap: var(--s-2);
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,210 @@
import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import * as R from "remeda";
import { Avatar } from "~/components/Avatar";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { DiscordIcon } from "~/components/icons/Discord";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { databaseTimestampToDate } from "~/utils/dates";
import {
SENDOU_INK_BASE_URL,
tournamentOrganizationPage,
tournamentPage,
userPage,
} from "~/utils/urls";
import { splitTournamentName } from "../core/Tournament";
import styles from "./TournamentHeader.module.css";
export function TournamentHeader({ tournament }: { tournament: Tournament }) {
const { name, subtext } = splitTournamentName(
tournament.ctx.name,
tournament.ctx.organization?.series ?? [],
);
const startTimes = R.uniqueBy(
[
tournament.ctx.startTime,
...tournament.ctx.settings.bracketProgression
.filter((b) => b.startTime)
.map((b) => databaseTimestampToDate(b.startTime!)),
],
(date) => date.getTime(),
);
// xxx: for dates use the popover version
return (
<header className={styles.header}>
<div className={styles.identity}>
<img
src={tournament.ctx.logoUrl}
alt=""
className={styles.logo}
width={125}
height={125}
/>
<div className={styles.titleBlock}>
<div className={styles.nameBlock}>
<h1 className={styles.name}>{name}</h1>
{subtext ? <div className={styles.subtext}>{subtext}</div> : null}
</div>
<OrganizerLink tournament={tournament} />
</div>
</div>
<div className={styles.dates}>
{startTimes.map((date) => (
<LocaleTime
key={date.getTime()}
date={date}
options={{
weekday: "long",
day: "numeric",
month: "long",
hour: "numeric",
minute: "numeric",
}}
/>
))}
</div>
</header>
);
}
export function TournamentHeaderActions({
tournament,
isSaved,
}: {
tournament: Tournament;
isSaved: boolean;
}) {
return (
<div className={styles.actions}>
<SaveTournamentButton tournament={tournament} isSaved={isSaved} />
{tournament.ctx.discordUrl ? (
<LinkButton
to={tournament.ctx.discordUrl}
variant="outlined"
size="small"
shape="circle"
isExternal
icon={<DiscordIcon />}
aria-label="Discord"
/>
) : null}
<ShareTournamentButton tournament={tournament} />
</div>
);
}
function SaveTournamentButton({
tournament,
isSaved,
}: {
tournament: Tournament;
isSaved: boolean;
}) {
const { t } = useTranslation(["common"]);
const user = useUser();
const fetcher = useFetcher();
const teamMemberOf = tournament.teamMemberOfByUser(user);
if (!user || tournament.hasStarted || teamMemberOf) return null;
const pending = fetcher.formData?.get("_action");
const displayedSaved =
pending === "SAVE_TOURNAMENT"
? true
: pending === "UNSAVE_TOURNAMENT"
? false
: isSaved;
return (
<fetcher.Form method="post" action={"./register"}>
<input
type="hidden"
name="_action"
value={displayedSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
/>
<SendouButton
type="submit"
variant="outlined"
size="small"
shape="circle"
icon={displayedSaved ? <BookmarkCheck /> : <Bookmark />}
aria-label={
displayedSaved ? t("common:actions.unsave") : t("common:actions.save")
}
/>
</fetcher.Form>
);
}
function OrganizerLink({ tournament }: { tournament: Tournament }) {
if (tournament.ctx.organization) {
return (
<Link
to={tournamentOrganizationPage({
organizationSlug: tournament.ctx.organization.slug,
tournamentName: tournament.ctx.name,
})}
className={styles.organizer}
>
<Avatar
url={tournament.ctx.organization.logoUrl ?? undefined}
size="xxs"
/>
{tournament.ctx.organization.name}
</Link>
);
}
return (
<Link to={userPage(tournament.ctx.author)} className={styles.organizer}>
<Avatar user={tournament.ctx.author} size="xxs" />
{tournament.ctx.author.username}
</Link>
);
}
function ShareTournamentButton({ tournament }: { tournament: Tournament }) {
const { t } = useTranslation(["common"]);
const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`;
const handleShare = () => {
navigator.share({ url });
};
if (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
) {
return (
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
onPress={handleShare}
aria-label={t("common:actions.share")}
/>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton
variant="outlined"
size="small"
shape="circle"
icon={<Share2 />}
aria-label={t("common:actions.share")}
/>
}
/>
);
}

View File

@@ -0,0 +1,149 @@
.nav {
display: flex;
align-items: center;
gap: var(--s-3);
padding: var(--s-2) 0;
margin-block-end: var(--s-4);
min-width: 0;
}
.identity {
display: flex;
align-items: center;
gap: var(--s-2);
color: var(--color-text);
text-decoration: none;
min-width: 0;
flex-shrink: 0;
}
.identityText {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
min-width: 0;
}
.identityName {
font-size: var(--font-sm);
font-weight: var(--weight-bold);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 14ch;
}
.identitySubtext {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--font-2xs);
font-weight: var(--weight-semi);
color: var(--color-text-high);
&::before,
&::after {
content: "";
flex: 1;
border-bottom: 1.5px solid var(--color-text-high);
}
}
.separator {
flex-shrink: 0;
width: 2px;
height: 28px;
background-color: var(--color-border);
}
.itemsWrapper {
flex: 1;
min-width: 0;
overflow: hidden;
}
.items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 4px;
flex-wrap: nowrap;
white-space: nowrap;
}
.itemSlot[data-hidden="true"] {
visibility: hidden;
pointer-events: none;
}
.link {
display: inline-flex;
align-items: center;
gap: var(--s-1-5);
padding: var(--s-1) var(--s-2-5);
border-radius: var(--radius-field);
color: var(--color-text);
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-decoration: none;
cursor: pointer;
&:hover {
background-color: var(--color-bg-high);
}
}
.linkActive {
color: var(--color-text-accent);
background-color: var(--color-bg-high);
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon > svg {
width: 16px;
height: 16px;
}
.label {
white-space: nowrap;
}
.hamburger {
flex-shrink: 0;
}
.overflowList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 12rem;
}
.overflowLink {
display: flex;
align-items: center;
gap: var(--s-2);
padding: var(--s-2) var(--s-3);
border-radius: var(--radius-field);
color: var(--color-text);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
text-decoration: none;
&:hover {
background-color: var(--color-bg-high);
}
}
.overflowLink.linkActive {
color: var(--color-text-accent);
}

View File

@@ -0,0 +1,351 @@
import clsx from "clsx";
import {
ClipboardCheck,
LayoutGrid,
ListOrdered,
Medal,
Menu,
ScrollText,
Settings,
Trophy,
Tv,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { NavLink } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { useUser } from "~/features/auth/core/user";
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import {
tournamentDivisionsPage,
tournamentInfoPage,
tournamentRulesPage,
} from "~/utils/urls";
import { splitTournamentName } from "../core/Tournament";
import styles from "./TournamentNav.module.css";
type NavItemKey =
| "register"
| "brackets"
| "teams"
| "divisions"
| "streams"
| "results"
| "rules"
| "lfg"
| "seeds"
| "admin";
interface NavItem {
key: NavItemKey;
label: string;
to: string;
icon: React.ReactNode;
end?: boolean;
testId?: string;
}
const PRIORITY_ORDER: NavItemKey[] = [
"register",
"lfg",
"brackets",
"teams",
"divisions",
"streams",
"results",
"rules",
"seeds",
"admin",
];
// xxx: icons shrinking
// xxx: sticky for desktop
// xxx: close popover when changing page
export function TournamentNav({
tournament,
hasChildTournaments,
}: {
tournament: Tournament;
hasChildTournaments: boolean;
}) {
const { t } = useTranslation(["tournament"]);
const navItems = useNavItems({ tournament, hasChildTournaments });
const { visibleCount, containerRef, measureRef } = useNavOverflow(
navItems.length,
);
const overflowItems = navItems.slice(visibleCount);
const { name, subtext } = splitTournamentName(
tournament.ctx.name,
tournament.ctx.organization?.series ?? [],
);
const homeHref = tournament.isLeagueDivision
? tournamentInfoPage(tournament.ctx.parentTournamentId!)
: tournamentInfoPage(tournament.ctx.id);
return (
<nav className={styles.nav} aria-label={t("tournament:nav.label")}>
<NavLink to={homeHref} className={styles.identity} end>
<Avatar url={tournament.ctx.logoUrl} size="sm" alt="" />
<div className={styles.identityText}>
<span className={styles.identityName}>{name}</span>
{subtext ? (
<span className={styles.identitySubtext}>{subtext}</span>
) : null}
</div>
</NavLink>
<div className={styles.separator} aria-hidden="true" />
<div className={styles.itemsWrapper} ref={containerRef}>
<ul className={styles.items} ref={measureRef}>
{navItems.map((item, index) => (
<li
key={item.key}
className={styles.itemSlot}
data-hidden={index >= visibleCount ? "true" : undefined}
>
<NavItemLink item={item} />
</li>
))}
</ul>
</div>
{overflowItems.length > 0 ? (
<SendouPopover
placement="bottom end"
trigger={
<SendouButton
variant="minimal"
icon={<Menu />}
aria-label={t("tournament:nav.moreItems")}
className={styles.hamburger}
/>
}
>
<ul className={styles.overflowList}>
{overflowItems.map((item) => (
<li key={item.key}>
<NavItemLink item={item} overflow />
</li>
))}
</ul>
</SendouPopover>
) : null}
</nav>
);
}
function useNavItems({
tournament,
hasChildTournaments,
}: {
tournament: Tournament;
hasChildTournaments: boolean;
}): NavItem[] {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const items: Partial<Record<NavItemKey, NavItem>> = {};
if (tournament.registrationOpen) {
items.register = {
key: "register",
label: t("tournament:nav.register"),
to: "register",
icon: <ClipboardCheck />,
testId: "register-tab",
};
}
const showBrackets = tournament.hasStarted && !tournament.isLeagueSignup;
if (showBrackets) {
items.brackets = {
key: "brackets",
label: t("tournament:nav.brackets"),
to: "brackets",
icon: <Trophy />,
testId: "brackets-tab",
};
}
const showTeams = !(tournament.isLeagueSignup && hasChildTournaments);
if (showTeams) {
items.teams = {
key: "teams",
label: t("tournament:nav.teams", {
count: tournament.ctx.teams.length,
}),
to: "teams",
icon: <Users />,
end: false,
testId: "teams-tab",
};
}
if (tournament.isLeagueSignup || tournament.isLeagueDivision) {
items.divisions = {
key: "divisions",
label: t("tournament:nav.divisions"),
to: tournamentDivisionsPage(
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
),
icon: <LayoutGrid />,
};
}
if (tournament.hasStarted && !tournament.everyBracketOver) {
items.streams = {
key: "streams",
label: t("tournament:nav.streams", {
count: tournament.streams.length,
}),
to: "streams",
icon: <Tv />,
};
}
if (tournament.hasStarted) {
items.results = {
key: "results",
label: t("tournament:nav.results"),
to: "results",
icon: <Medal />,
testId: "results-tab",
};
}
if (tournament.ctx.rules) {
items.rules = {
key: "rules",
label: t("tournament:nav.rules"),
to: tournamentRulesPage(tournament.ctx.id),
icon: <ScrollText />,
};
}
const showLfg =
!tournament.isInvitational &&
!tournament.everyBracketOver &&
!(tournament.isLeagueSignup && !tournament.registrationOpen) &&
tournament.lfgEnabled;
if (showLfg) {
items.lfg = {
key: "lfg",
label: tournament.registrationOpen
? t("tournament:nav.looking")
: t("tournament:nav.subs"),
to: "looking",
icon: <UserPlus />,
};
}
const showSeeds =
tournament.isOrganizer(user) &&
!tournament.hasStarted &&
!tournament.isLeagueSignup;
if (showSeeds) {
items.seeds = {
key: "seeds",
label: t("tournament:nav.seeds"),
to: "seeds",
icon: <ListOrdered />,
};
}
const showAdmin =
tournament.isOrganizer(user) &&
(!tournament.ctx.isFinalized || DANGEROUS_CAN_ACCESS_DEV_CONTROLS);
if (showAdmin) {
items.admin = {
key: "admin",
label: t("tournament:nav.admin"),
to: "admin",
icon: <Settings />,
testId: "admin-tab",
};
}
return PRIORITY_ORDER.flatMap((key) => (items[key] ? [items[key]!] : []));
}
function NavItemLink({
item,
overflow = false,
}: {
item: NavItem;
overflow?: boolean;
}) {
return (
<NavLink
to={item.to}
end={item.end ?? true}
prefetch="intent"
className={({ isActive }) =>
clsx(overflow ? styles.overflowLink : styles.link, {
[styles.linkActive]: isActive,
})
}
data-testid={item.testId}
>
<span className={styles.icon} aria-hidden="true">
{item.icon}
</span>
<span className={styles.label}>{item.label}</span>
</NavLink>
);
}
const ITEM_GAP = 4;
function useNavOverflow(totalItems: number) {
const containerRef = React.useRef<HTMLDivElement>(null);
const measureRef = React.useRef<HTMLUListElement>(null);
const [visibleCount, setVisibleCount] = React.useState(totalItems);
useIsomorphicLayoutEffect(() => {
const container = containerRef.current;
const list = measureRef.current;
if (!container || !list) return;
const slots = Array.from(list.children) as HTMLElement[];
const computeVisible = () => {
const containerWidth = container.clientWidth;
let used = 0;
let count = 0;
for (const slot of slots) {
const width = slot.scrollWidth + (count === 0 ? 0 : ITEM_GAP);
if (used + width <= containerWidth) {
used += width;
count++;
} else {
break;
}
}
setVisibleCount(count);
};
computeVisible();
const observer = new ResizeObserver(() => computeVisible());
observer.observe(container);
for (const slot of slots) {
observer.observe(slot);
}
return () => observer.disconnect();
}, [totalItems]);
return { visibleCount, containerRef, measureRef };
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { bracketProgressionLabel, splitTournamentName } from "./Tournament";
describe("splitTournamentName", () => {
const series = [{ name: "In The Zone" }, { name: "Low Ink" }];
it("splits the trailing number subtext after the series name", () => {
expect(splitTournamentName("In The Zone 54", series)).toEqual({
name: "In The Zone",
subtext: "54",
});
});
it("splits a non-numeric subtext after the series name", () => {
expect(splitTournamentName("Low Ink May 2026", series)).toEqual({
name: "Low Ink",
subtext: "May 2026",
});
});
it("matches the series name case-insensitively", () => {
expect(splitTournamentName("in the zone 54", series)).toEqual({
name: "In The Zone",
subtext: "54",
});
});
it("strips separators between the series name and the subtext", () => {
expect(splitTournamentName("In The Zone - 54", series)).toEqual({
name: "In The Zone",
subtext: "54",
});
});
it("trims trailing whitespace after the subtext", () => {
expect(splitTournamentName("In The Zone 54 ", series)).toEqual({
name: "In The Zone",
subtext: "54",
});
});
it("returns name only when the name does not start with a series name", () => {
expect(splitTournamentName("Picnic Weekly", series)).toEqual({
name: "Picnic Weekly",
});
});
it("returns name only when the name equals the series name", () => {
expect(splitTournamentName("In The Zone", series)).toEqual({
name: "In The Zone",
});
});
it("returns name only when there are no series", () => {
expect(splitTournamentName("In The Zone 54", [])).toEqual({
name: "In The Zone 54",
});
});
it("prefers the longest matching series name", () => {
expect(
splitTournamentName("In The Zone Masters 5", [
{ name: "In The Zone" },
{ name: "In The Zone Masters" },
]),
).toEqual({
name: "In The Zone Masters",
subtext: "5",
});
});
});
describe("bracketProgressionLabel", () => {
it("returns the short code for a single stage", () => {
expect(bracketProgressionLabel([{ type: "single_elimination" }])).toBe(
"SE",
);
});
it("joins stages with an arrow", () => {
expect(
bracketProgressionLabel([
{ type: "round_robin" },
{ type: "single_elimination" },
]),
).toBe("RR → SE");
});
it("collapses consecutive duplicate stages", () => {
expect(
bracketProgressionLabel([
{ type: "single_elimination" },
{ type: "single_elimination" },
{ type: "double_elimination" },
]),
).toBe("SE → DE");
});
it("returns empty string for empty progression", () => {
expect(bracketProgressionLabel([])).toBe("");
});
});

View File

@@ -0,0 +1,78 @@
import type { TournamentStage } from "~/db/tables";
import type { ParsedBracket } from "../../tournament-bracket/core/Progression";
const LEADING_SEPARATOR_REGEX = /^[\s_-]+/;
/**
* Splits a tournament name into its series name and a trailing "subtext"
* (e.g. an edition number like `"54"` or a date like `"May 2026"`) based on the
* names of the organization's tournament series.
*
* The longest series name that the tournament name starts with (case-insensitive)
* is treated as the base name and whatever follows it becomes the subtext. If the
* tournament name does not start with any of the series names, the whole name is
* returned with no subtext.
*
* @example
* // series: [{ name: "In The Zone" }]
* splitTournamentName("In The Zone 54", series) // { name: "In The Zone", subtext: "54" }
* splitTournamentName("In The Zone Winter", series) // { name: "In The Zone", subtext: "Winter" }
* splitTournamentName("Picnic Weekly", series) // { name: "Picnic Weekly" }
*/
export function splitTournamentName(
tournamentName: string,
series: Array<{ name: string }>,
): { name: string; subtext?: string } {
const trimmedName = tournamentName.trim();
const nameLower = trimmedName.toLowerCase();
const matchingSeries = series
.filter((s) => nameLower.startsWith(s.name.toLowerCase()))
.sort((a, b) => b.name.length - a.name.length)
.at(0);
if (!matchingSeries) return { name: trimmedName };
const subtext = trimmedName
.slice(matchingSeries.name.length)
.replace(LEADING_SEPARATOR_REGEX, "")
.trim();
if (!subtext) return { name: matchingSeries.name };
return { name: matchingSeries.name, subtext };
}
const STAGE_TYPE_TO_SHORT_CODE: Record<TournamentStage["type"], string> = {
single_elimination: "SE",
double_elimination: "DE",
round_robin: "RR",
swiss: "SW",
};
/**
* Builds a compact arrow-separated label describing the bracket progression of a tournament,
* derived from `settings.bracketProgression`.
*
* Each stage type is rendered as a short code (`RR`, `SE`, `DE`, `SW`) and consecutive duplicates
* are collapsed so e.g. two single-elimination stages still render as a single `SE`.
*
* @example
* // [{type: "round_robin"}, {type: "single_elimination"}]
* bracketProgressionLabel(progression) // "RR → SE"
*/
export function bracketProgressionLabel(
progression: Pick<ParsedBracket, "type">[],
): string {
if (progression.length === 0) return "";
const codes: string[] = [];
for (const bracket of progression) {
const code = STAGE_TYPE_TO_SHORT_CODE[bracket.type];
if (codes.at(-1) !== code) {
codes.push(code);
}
}
return codes.join(" → ");
}

View File

@@ -0,0 +1,24 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as SavedCalendarEventRepository from "~/features/tournament/SavedCalendarEventRepository.server";
import { parseParams } from "~/utils/remix.server";
import { idObject } from "~/utils/zod";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = getUser();
const { id: tournamentId } = parseParams({
params,
schema: idObject,
});
if (!user) {
return { isSaved: false };
}
return {
isSaved: await SavedCalendarEventRepository.isSaved({
userId: user.id,
tournamentId,
}),
};
};

View File

@@ -3,7 +3,7 @@ import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tourn
import { parseParams } from "~/utils/remix.server";
import {
tournamentBracketsPage,
tournamentRegisterPage,
tournamentInfoPage,
tournamentResultsPage,
} from "~/utils/urls";
import { idObject } from "~/utils/zod";
@@ -20,7 +20,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
});
if (!tournament.hasStarted) {
return redirect(tournamentRegisterPage(tournamentId));
return redirect(tournamentInfoPage(tournamentId));
}
if (!tournament.ctx.isFinalized) {

View File

@@ -0,0 +1,33 @@
.description {
white-space: pre-wrap;
& > :is(h1, h2, h3, h4, h5, h6) {
margin-block-end: var(--s-4);
}
& > :is(h2, h3, h4, h5, h6) {
margin-block-start: var(--s-6);
}
& > :first-child {
margin-block-start: 0;
}
& > h1 {
font-size: var(--font-xl);
}
& > :is(h2, h3, h4, h5, h6) {
font-size: var(--font-lg);
}
& > :is(h3, h4, h5, h6) {
font-size: var(--font-md);
}
}
.modes {
display: inline-flex;
gap: var(--s-1);
flex-wrap: wrap;
}

View File

@@ -0,0 +1,117 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { ModeImage } from "~/components/Image";
import { containerClassName } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import { TierPill } from "~/components/TierPill";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FactCardGrid, type FactCardItem } from "../components/FactCard";
import { RegistrationActions } from "../components/RegistrationActions";
import {
TournamentHeader,
TournamentHeaderActions,
} from "../components/TournamentHeader";
import { bracketProgressionLabel } from "../core/Tournament";
import { loader } from "../loaders/to.$id.info.server";
import { useTournament } from "./to.$id";
import styles from "./to.$id.info.module.css";
export { loader };
export const handle: SendouRouteHandle = {
i18n: ["tournament"],
};
// xxx: align round outlined buttons and fact card
export default function TournamentInfoPage() {
const tournament = useTournament();
const data = useLoaderData<typeof loader>();
const facts = useFacts(tournament);
return (
<div className={clsx("stack lg", containerClassName("normal"))}>
<TournamentHeader tournament={tournament} />
<div className="stack md">
<FactCardGrid facts={facts} />
<TournamentHeaderActions
tournament={tournament}
isSaved={data.isSaved}
/>
</div>
<RegistrationActions tournament={tournament} />
{tournament.ctx.description ? (
<section className={styles.description}>
<Markdown>{tournament.ctx.description}</Markdown>
</section>
) : null}
</div>
);
}
function useFacts(
tournament: ReturnType<typeof useTournament>,
): FactCardItem[] {
const { t } = useTranslation(["tournament"]);
const teamSizeValue =
tournament.minMembersPerTeam === tournament.maxMembersPerTeam
? `${tournament.minMembersPerTeam}`
: `${tournament.minMembersPerTeam}${tournament.maxMembersPerTeam}`;
const showsEstimatedTier = !tournament.ctx.tier && !tournament.hasStarted;
const rankedSeason = Seasons.current(tournament.ctx.startTime);
return [
{
label: t("tournament:fact.format"),
value: `${tournament.minMembersPerTeam}v${tournament.minMembersPerTeam}`,
},
{
label: t("tournament:fact.bracket"),
value: bracketProgressionLabel(
tournament.ctx.settings.bracketProgression,
),
},
{
label: t("tournament:fact.modes"),
value: (
<div className={styles.modes}>
{tournament.modesIncluded.map((mode) => (
<ModeImage key={mode} mode={mode} size={20} />
))}
</div>
),
},
{
label: showsEstimatedTier
? t("tournament:fact.tier.est")
: t("tournament:fact.tier"),
value: tournament.ctx.tier ? (
<TierPill tier={tournament.ctx.tier} />
) : showsEstimatedTier && tournament.ctx.tentativeTier ? (
<TierPill tier={tournament.ctx.tentativeTier} isTentative />
) : (
"-"
),
},
{
label: t("tournament:fact.ranked"),
value:
tournament.ranked && rankedSeason
? t("tournament:fact.ranked.yesWithSeason", {
season: rankedSeason.nth,
})
: tournament.ranked
? t("tournament:fact.ranked.yes")
: t("tournament:fact.ranked.no"),
},
{
label: t("tournament:fact.teamSize"),
value: teamSizeValue,
},
];
}

View File

@@ -1,45 +1,21 @@
import clsx from "clsx";
import Compressor from "compressorjs";
import {
AlertCircle,
Bookmark,
BookmarkCheck,
Check,
Clock,
Share2,
Trash,
User,
X,
} from "lucide-react";
import { AlertCircle, Check, Trash, X } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Form, Link, useFetcher, useLoaderData } from "react-router";
import { Form, useFetcher, useLoaderData } from "react-router";
import { useCopyToClipboard } from "react-use";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { CopyToClipboardPopover } from "~/components/CopyToClipboardPopover";
import { Divider } from "~/components/Divider";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { FriendCodePopover } from "~/components/FriendCodePopover";
import { Image, ModeImage } from "~/components/Image";
import { Input } from "~/components/Input";
import { DiscordIcon } from "~/components/icons/Discord";
import { Label } from "~/components/Label";
import { containerClassName } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Markdown } from "~/components/Markdown";
import { Section } from "~/components/Section";
import { SubmitButton } from "~/components/SubmitButton";
import { TierPill } from "~/components/TierPill";
import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
@@ -48,21 +24,14 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useHydrated } from "~/hooks/useHydrated";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
LOG_IN_URL,
mapsPageWithMapPool,
navIconUrl,
SENDOU_INK_BASE_URL,
tournamentJoinPage,
tournamentOrganizationPage,
tournamentPage,
tournamentSubsPage,
userEditProfilePage,
userPage,
} from "~/utils/urls";
import { action } from "../actions/to.$id.register.server";
import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server";
@@ -78,101 +47,6 @@ import { useTournament } from "./to.$id";
export { action, loader };
export default function TournamentRegisterPage() {
const isHydrated = useHydrated();
const tournament = useTournament();
return (
<div className={clsx("stack lg", containerClassName("normal"))}>
<div className={styles.logoContainer}>
<img
src={tournament.ctx.logoUrl}
alt=""
className={styles.logo}
width={124}
height={124}
/>
<div>
<div className={styles.title}>{tournament.ctx.name}</div>
<div>
{tournament.ctx.organization ? (
<Link
to={tournamentOrganizationPage({
organizationSlug: tournament.ctx.organization.slug,
tournamentName: tournament.ctx.name,
})}
className="stack horizontal sm items-center text-xs text-main-forced"
>
<Avatar
url={tournament.ctx.organization.logoUrl ?? undefined}
size="xxs"
/>
{tournament.ctx.organization.name}
</Link>
) : (
<Link
to={userPage(tournament.ctx.author)}
className="stack horizontal xs items-center text-lighter"
>
<User className={styles.infoIcon} />{" "}
{tournament.ctx.author.username}
</Link>
)}
</div>
{!tournament.isLeagueSignup ? (
<div className={clsx(styles.by, "mt-2")}>
<div className="stack horizontal xs items-center">
<Clock className={styles.infoIcon} />{" "}
{isHydrated ? (
<TimePopover
time={tournament.ctx.startTime}
options={{
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
year:
tournament.ctx.startTime.getFullYear() !==
new Date().getFullYear()
? "2-digit"
: undefined,
}}
/>
) : null}
</div>
</div>
) : null}
<div className="stack horizontal sm mt-1">
{tournament.ranked ? (
<div className={clsx(styles.badge, styles.badgeRanked)}>
Ranked
</div>
) : (
<div className={clsx(styles.badge, styles.badgeUnranked)}>
Unranked
</div>
)}
{tournament.ctx.tier ? (
<TierPill tier={tournament.ctx.tier} />
) : tournament.ctx.tentativeTier && !tournament.hasStarted ? (
<TierPill tier={tournament.ctx.tentativeTier} isTentative />
) : null}
<div className={clsx(styles.badge, styles.badgeModes)}>
{tournament.modesIncluded.map((mode) => (
<ModeImage key={mode} mode={mode} size={16} />
))}
</div>
</div>
</div>
</div>
<TournamentRegisterInfoTabs />
</div>
);
}
const TABS = ["description", "rules", "register"] as const;
type RegisterPageTab = (typeof TABS)[number];
function TournamentRegisterInfoTabs() {
const user = useUser();
const tournament = useTournament();
const { t } = useTranslation(["tournament"]);
@@ -181,18 +55,6 @@ function TournamentRegisterInfoTabs() {
const teamOwned = tournament.ownedTeamByUser(user);
const isRegularMemberOfATeam = teamMemberOf && !teamOwned;
const defaultTab = (): RegisterPageTab => {
if (tournament.hasStarted || !teamOwned) return "description";
return "register";
};
const [tabKey, setTabKey] = useSearchParamState({
defaultValue: defaultTab(),
name: "tab",
revive: (val) =>
TABS.includes(val as RegisterPageTab) ? (val as RegisterPageTab) : null,
});
const showAddIGNAlert =
tournament.ctx.settings.requireInGameNames &&
!teamOwned &&
@@ -200,97 +62,26 @@ function TournamentRegisterInfoTabs() {
!user?.inGameName;
return (
<div>
<SendouTabs
selectedKey={tabKey}
onSelectionChange={(key) => setTabKey(key as RegisterPageTab)}
>
<SendouTabList sticky>
<SendouTab id="description">Description</SendouTab>
{tournament.ctx.rules ? (
<SendouTab id="rules">Rules</SendouTab>
) : null}
{!tournament.hasStarted ? (
<SendouTab id="register" data-testid="register-tab">
Register
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="description">
<div className="stack lg">
<div className="stack horizontal sm">
{tournament.ctx.discordUrl ? (
<div className="w-max">
<LinkButton
to={tournament.ctx.discordUrl}
variant="outlined"
size="small"
isExternal
icon={<DiscordIcon />}
>
Join the Discord
</LinkButton>
</div>
) : null}
<SaveTournamentButton />
<ShareTournamentButton />
<div className={clsx("stack lg", containerClassName("normal"))}>
{isRegularMemberOfATeam ? (
<div className="stack md items-center">
<Alert>{t("tournament:pre.inATeam")}</Alert>
<LeaveTeamControl />
</div>
) : showAddIGNAlert ? (
<div>
<Alert variation="WARNING">
<div className="stack horizontal sm items-center flex-wrap justify-center text-center">
This tournament requires you to have an in-game name set{" "}
<LinkButton to={userEditProfilePage(user)} size="small">
Edit profile
</LinkButton>
</div>
<div className={styles.infoDescription}>
<Markdown>{tournament.ctx.description ?? ""}</Markdown>
</div>
<TOPickedMapPoolInfo />
<TiebreakerMapPoolInfo />
</div>
</SendouTabPanel>
{tournament.ctx.rules ? (
<SendouTabPanel id="rules">
<div className={styles.infoDescription}>
<Markdown>{tournament.ctx.rules ?? ""}</Markdown>
</div>
</SendouTabPanel>
) : null}
{!tournament.hasStarted ? (
<SendouTabPanel id="register">
<div className="stack lg">
{isRegularMemberOfATeam ? (
<div className="stack md items-center">
<Alert>{t("tournament:pre.inATeam")}</Alert>
<LeaveTeamControl />
</div>
) : showAddIGNAlert ? (
<div>
<Alert variation="WARNING">
<div className="stack horizontal sm items-center flex-wrap justify-center text-center">
This tournament requires you to have an in-game name set{" "}
<LinkButton to={userEditProfilePage(user)} size="small">
Edit profile
</LinkButton>
</div>
</Alert>
</div>
) : (
<RegistrationForms />
)}
{user &&
!tournament.teamMemberOfByUser(user) &&
tournament.canAddNewSubPost &&
!showAddIGNAlert &&
!tournament.hasStarted ? (
<Link
to={tournamentSubsPage(tournament.ctx.id)}
className="text-xs text-center"
>
{t("tournament:pre.sub.prompt")}
</Link>
) : null}
</div>
</SendouTabPanel>
) : null}
</SendouTabs>
</Alert>
</div>
) : (
<RegistrationForms />
)}
</div>
);
}
@@ -653,7 +444,6 @@ function TeamInfo({
const formData = new FormData(ref.current!);
if (uploadedAvatar) {
// replace with the compressed version
formData.delete("img");
formData.append("img", uploadedAvatar, uploadedAvatar.name);
}
@@ -870,7 +660,6 @@ function TournamentLogoUpload({
width: logoDimensions.width,
maxHeight: logoDimensions.height,
maxWidth: logoDimensions.width,
// 0.5MB
convertSize: 500_000,
resize: "cover",
success(result) {
@@ -1308,120 +1097,3 @@ function MapPoolValidationStatusMessage({
</div>
);
}
function SaveTournamentButton() {
const { t } = useTranslation(["common"]);
const user = useUser();
const tournament = useTournament();
const data = useLoaderData<TournamentRegisterPageLoader>();
const fetcher = useFetcher();
const teamMemberOf = tournament.teamMemberOfByUser(user);
if (!user || tournament.hasStarted || teamMemberOf) return null;
const isSaved =
fetcher.formData?.get("_action") === "SAVE_TOURNAMENT"
? true
: fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT"
? false
: (data?.isSaved ?? false);
return (
<fetcher.Form method="post">
<input
type="hidden"
name="_action"
value={isSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
/>
<SendouButton
type="submit"
variant="outlined"
size="small"
icon={isSaved ? <BookmarkCheck /> : <Bookmark />}
>
{isSaved ? t("common:actions.unsave") : t("common:actions.save")}
</SendouButton>
</fetcher.Form>
);
}
function ShareTournamentButton() {
const { t } = useTranslation(["common"]);
const tournament = useTournament();
const url = `${SENDOU_INK_BASE_URL}${tournamentPage(tournament.ctx.id)}`;
const handleShare = () => {
navigator.share({ url });
};
if (
typeof navigator !== "undefined" &&
typeof navigator.share === "function"
) {
return (
<SendouButton
variant="outlined"
size="small"
icon={<Share2 />}
onPress={handleShare}
>
{t("common:actions.share")}
</SendouButton>
);
}
return (
<CopyToClipboardPopover
url={url}
trigger={
<SendouButton variant="outlined" size="small" icon={<Share2 />}>
{t("common:actions.share")}
</SendouButton>
}
/>
);
}
function TOPickedMapPoolInfo() {
const { t } = useTranslation(["calendar"]);
const tournament = useTournament();
if (tournament.ctx.toSetMapPool.length === 0) return null;
const mapPool = new MapPool(tournament.ctx.toSetMapPool);
return (
<Section title={t("calendar:forms.mapPool")}>
<div>
<MapPoolStages mapPool={mapPool} />
<div className="stack items-center mt-4">
<LinkButton to={mapsPageWithMapPool(mapPool)} variant="outlined">
<Image alt="" path={navIconUrl("maps")} width={22} height={22} />
{t("calendar:createMapList")}
</LinkButton>
</div>
</div>
</Section>
);
}
function TiebreakerMapPoolInfo() {
const { t } = useTranslation(["game-misc"]);
const tournament = useTournament();
if (tournament.ctx.tieBreakerMapPool.length === 0) return null;
return (
<div className="text-sm text-lighter text-semi-bold">
Tiebreaker map pool:{" "}
{tournament.ctx.tieBreakerMapPool
.sort((a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode))
.map(
(map) =>
`${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`,
)
.join(", ")}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { LinkButton } from "~/components/elements/Button";
import { Image } from "~/components/Image";
import { containerClassName } from "~/components/Main";
import { MapPoolStages } from "~/components/MapPoolSelector";
import { Markdown } from "~/components/Markdown";
import { Section } from "~/components/Section";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { mapsPageWithMapPool, navIconUrl } from "~/utils/urls";
import { useTournament } from "./to.$id";
import styles from "./to.$id.info.module.css";
export const handle: SendouRouteHandle = {
i18n: ["tournament", "calendar", "game-misc"],
};
export default function TournamentRulesPage() {
const tournament = useTournament();
return (
<div className={clsx("stack lg", containerClassName("normal"))}>
{tournament.ctx.rules ? (
<section className={styles.description}>
<Markdown>{tournament.ctx.rules}</Markdown>
</section>
) : null}
<CounterPickMapPool />
<TiebreakerMapPool />
</div>
);
}
function CounterPickMapPool() {
const { t } = useTranslation(["calendar"]);
const tournament = useTournament();
if (tournament.ctx.toSetMapPool.length === 0) return null;
const mapPool = new MapPool(tournament.ctx.toSetMapPool);
return (
<Section title={t("calendar:forms.mapPool")}>
<div>
<MapPoolStages mapPool={mapPool} />
<div className="stack items-center mt-4">
<LinkButton to={mapsPageWithMapPool(mapPool)} variant="outlined">
<Image alt="" path={navIconUrl("maps")} width={22} height={22} />
{t("calendar:createMapList")}
</LinkButton>
</div>
</div>
</Section>
);
}
function TiebreakerMapPool() {
const { t } = useTranslation(["game-misc"]);
const tournament = useTournament();
if (tournament.ctx.tieBreakerMapPool.length === 0) return null;
return (
<div className="text-sm text-lighter text-semi-bold">
Tiebreaker map pool:{" "}
{tournament.ctx.tieBreakerMapPool
.sort((a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode))
.map(
(map) =>
`${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`,
)
.join(", ")}
</div>
);
}

View File

@@ -1,5 +1,4 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import {
Outlet,
@@ -9,20 +8,14 @@ import {
} from "react-router";
import { Main } from "~/components/Main";
import { Placeholder } from "~/components/Placeholder";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { Tournament } from "~/features/tournament-bracket/core/Tournament";
import { useHydrated } from "~/hooks/useHydrated";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { removeMarkdown } from "~/utils/strings";
import {
tournamentDivisionsPage,
tournamentPage,
tournamentRegisterPage,
} from "~/utils/urls";
import { tournamentPage } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { TournamentNav } from "../components/TournamentNav";
import { loader, type TournamentLoaderData } from "../loaders/to.$id.server";
@@ -99,8 +92,6 @@ export default function TournamentLayoutShell() {
}
export function TournamentLayout() {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const rawData = useLoaderData<typeof loader>();
const data = React.useMemo(
() => JSON.parse(rawData) as TournamentLoaderData,
@@ -124,85 +115,10 @@ export function TournamentLayout() {
}
return (
<Main bigger>
<SubNav>
<SubNavLink
to={tournamentRegisterPage(
tournament.isLeagueDivision
? tournament.ctx.parentTournamentId!
: tournament.ctx.id,
)}
data-testid="register-tab"
prefetch="intent"
>
{tournament.hasStarted || tournament.isLeagueDivision
? "Info"
: t("tournament:tabs.register")}
</SubNavLink>
{!tournament.isLeagueSignup ? (
<SubNavLink
to="brackets"
data-testid="brackets-tab"
prefetch="render"
>
{t("tournament:tabs.brackets")}
</SubNavLink>
) : null}
{tournament.isLeagueSignup || tournament.isLeagueDivision ? (
<SubNavLink
to={tournamentDivisionsPage(
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
)}
>
Divisions
</SubNavLink>
) : null}
{!(tournament.isLeagueSignup && data.hasChildTournaments) ? (
<SubNavLink
to="teams"
end={false}
prefetch="render"
data-testid="teams-tab"
>
{t("tournament:tabs.teams", {
count: tournament.ctx.teams.length,
})}
</SubNavLink>
) : null}
{!tournament.isInvitational &&
!tournament.everyBracketOver &&
!(tournament.isLeagueSignup && !tournament.registrationOpen) &&
tournament.lfgEnabled ? (
<SubNavLink to="looking">
{tournament.registrationOpen
? t("tournament:tabs.looking")
: t("tournament:tabs.subs")}
</SubNavLink>
) : null}
{tournament.hasStarted && !tournament.everyBracketOver ? (
<SubNavLink to="streams">
{t("tournament:tabs.streams", {
count: tournament.streams.length,
})}
</SubNavLink>
) : null}
{tournament.hasStarted ? (
<SubNavLink to="results" data-testid="results-tab">
{t("tournament:tabs.results")}
</SubNavLink>
) : null}
{tournament.isOrganizer(user) &&
!tournament.hasStarted &&
!tournament.isLeagueSignup && (
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
)}
{tournament.isOrganizer(user) &&
(!tournament.ctx.isFinalized ||
DANGEROUS_CAN_ACCESS_DEV_CONTROLS) && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}
</SubNavLink>
)}
</SubNav>
<TournamentNav
tournament={tournament}
hasChildTournaments={data.hasChildTournaments}
/>
<TournamentContext.Provider value={tournament}>
<Outlet
context={

View File

@@ -524,7 +524,7 @@
.standingsDivider {
width: 5px;
background-color: var(--color-bg-high);
background-color: var(--color-border-high);
border-radius: var(--radius-box);
}

View File

@@ -120,7 +120,9 @@ export default [
route("/to/search", "features/tournament/routes/to.search.ts"),
route("/to/:id", "features/tournament/routes/to.$id.tsx", [
index("features/tournament/routes/to.$id.index.ts"),
route("info", "features/tournament/routes/to.$id.info.tsx"),
route("register", "features/tournament/routes/to.$id.register.tsx"),
route("rules", "features/tournament/routes/to.$id.rules.tsx"),
route("teams", "features/tournament/routes/to.$id.teams.tsx"),
route("teams/:tid", "features/tournament/routes/to.$id.teams.$tid.tsx"),
route("join", "features/tournament/routes/to.$id.join.tsx"),

View File

@@ -313,8 +313,12 @@ export const tournamentTeamPage = ({
tournamentId: number;
tournamentTeamId: number;
}) => `/to/${tournamentId}/teams/${tournamentTeamId}`;
export const tournamentInfoPage = (tournamentId: number) =>
`/to/${tournamentId}/info`;
export const tournamentRegisterPage = (tournamentId: number) =>
`/to/${tournamentId}/register`;
export const tournamentRulesPage = (tournamentId: number) =>
`/to/${tournamentId}/rules`;
export const tournamentAdminPage = (tournamentId: number) =>
`/to/${tournamentId}/admin`;
export const tournamentBracketsPage = ({

View File

@@ -140,7 +140,7 @@ test.describe("Tournament Organization", () => {
});
// Try to create a team
await page.getByRole("tab", { name: "Register" }).click();
await page.getByTestId("register-cta").click();
// Fill in team details
await page.getByLabel("Team name").fill("Banned Team");
@@ -164,7 +164,7 @@ test.describe("Tournament Organization", () => {
page,
url: tournamentPage(1),
});
await page.getByRole("tab", { name: "Register" }).click();
await page.getByTestId("register-cta").click();
// Try to create a team again
await expect(page.getByText(/Teams \(\d+\)/)).toBeVisible();

View File

@@ -4,6 +4,7 @@ import type { StageId } from "~/modules/in-game-lists/types";
import {
tournamentBracketsPage,
tournamentPage,
tournamentRegisterPage,
tournamentTeamsPage,
} from "~/utils/urls";
import {
@@ -67,7 +68,7 @@ test.describe("Tournament", () => {
url: tournamentPage(1),
});
await page.getByRole("tab", { name: "Register" }).click();
await page.getByTestId("register-cta").click();
await page.getByLabel("Pick-up name").fill("Chimera");
await page.getByTestId("save-team-button").click();
@@ -106,7 +107,10 @@ test.describe("Tournament", () => {
await isNotVisible(page.getByText("Chimera"));
await page.getByTestId("register-tab").click();
await navigate({
page,
url: tournamentRegisterPage(3),
});
await submit(page, "check-in-button");
await page.getByTestId("brackets-tab").click();

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Information",
"tabs.teams": "Hold ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Register",
"tabs.brackets": "Grupper",
"tabs.seeds": "Seedninger",
"tabs.results": "",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Suppleanter",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Grupper",
"nav.register": "",
"nav.teams": "Hold ({{count}})",
"nav.streams": "Streams ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Suppleanter",
"nav.divisions": "",
"nav.seeds": "Seedninger",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Fuldfør disse trin for at spille",
"pre.steps.name": "Holdnavn",
"pre.steps.roster": "Holdmedlemmer",
@@ -35,7 +50,6 @@
"pre.pool.header": "Vælg banepulje",
"pre.pool.banned": "Bandlyst",
"pre.pool.tiebreaker.short": "Tiebreaker",
"pre.sub.prompt": "Fandt du ikke et hold til denne begivenhed? Du kan skrive dig på listen af Suppleant.",
"bracket.type.DE_WINNERS": "Runde",
"bracket.type.DE_LOSERS": "Taber Runde",
"bracket.type.SE": "Runde",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Teams ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registrieren",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.results": "Ergebnisse",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Ersatzspieler",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Brackets",
"nav.register": "",
"nav.teams": "Teams ({{count}})",
"nav.streams": "Streams ({{count}})",
"nav.results": "Ergebnisse",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Ersatzspieler",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Folge diesen Schritten, um zu spielen",
"pre.steps.name": "Teamname",
"pre.steps.roster": "Volles Roster",
@@ -35,7 +50,6 @@
"pre.pool.header": "Arenenpool wählen",
"pre.pool.banned": "Gebannt",
"pre.pool.tiebreaker.short": "Tiebreaker",
"pre.sub.prompt": "Kein Team für dieses Event im Sinn? Du kannst dich auch als Ersatzspieler eintragen.",
"bracket.type.DE_WINNERS": "Sieger-Runde",
"bracket.type.DE_LOSERS": "Verlierer-Runde",
"bracket.type.SE": "Runde",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Teams ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Register",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.results": "Results",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs",
"tabs.looking": "LFG",
"nav.label": "Tournament navigation",
"nav.moreItems": "More",
"nav.brackets": "Brackets",
"nav.register": "Register",
"nav.teams": "Teams ({{count}})",
"nav.streams": "Streams ({{count}})",
"nav.results": "Results",
"nav.rules": "Rules",
"nav.looking": "LFG",
"nav.subs": "Subs",
"nav.divisions": "Divisions",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "Find team",
"registerNow": "Register now",
"fact.format": "Format",
"fact.bracket": "Bracket",
"fact.modes": "Modes",
"fact.tier": "Tier",
"fact.tier.est": "Est. tier",
"fact.ranked": "Ranked",
"fact.ranked.yes": "Yes",
"fact.ranked.yesWithSeason": "Yes (S{{season}})",
"fact.ranked.no": "No",
"fact.teamSize": "Team size",
"pre.steps.header": "Complete these steps to play",
"pre.steps.name": "Team name",
"pre.steps.roster": "Full roster",
@@ -35,7 +50,6 @@
"pre.pool.header": "Pick map pool",
"pre.pool.banned": "Banned",
"pre.pool.tiebreaker.short": "Tiebreaker",
"pre.sub.prompt": "No team in mind for this event? You can also join the list of subs.",
"bracket.type.DE_WINNERS": "Winners Round",
"bracket.type.DE_LOSERS": "Losers Round",
"bracket.type.SE": "Round",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Equipos ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registrar",
"tabs.brackets": "Cuadros de Torneo",
"tabs.seeds": "Listas de Equipos",
"tabs.results": "Resultados",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Cuadros de Torneo",
"nav.register": "",
"nav.teams": "Equipos ({{count}})",
"nav.streams": "Streams ({{count}})",
"nav.results": "Resultados",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Subs",
"nav.divisions": "",
"nav.seeds": "Listas de Equipos",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Completa estos pasos para jugar",
"pre.steps.name": "Nombre de equipo",
"pre.steps.roster": "Equipo lleno",
@@ -35,7 +50,6 @@
"pre.pool.header": "Escojer grupo de mapas",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
"pre.sub.prompt": "¿No tienes equipo en mente para este evento? También puedes unirte a la lista de substitutos.",
"bracket.type.DE_WINNERS": "Cuadro de ganadores",
"bracket.type.DE_LOSERS": "Cuadro de perdedores",
"bracket.type.SE": "Cuadro",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Equipos ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registrar",
"tabs.brackets": "Cuadros de Torneo",
"tabs.seeds": "Listas de Equipos",
"tabs.results": "",
"tabs.streams": "Streams ({{count}})",
"tabs.subs": "Subs",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Cuadros de Torneo",
"nav.register": "",
"nav.teams": "Equipos ({{count}})",
"nav.streams": "Streams ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Subs",
"nav.divisions": "",
"nav.seeds": "Listas de Equipos",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Completa estos pasos para jugar",
"pre.steps.name": "Nombre de equipo",
"pre.steps.roster": "Equipo lleno",
@@ -35,7 +50,6 @@
"pre.pool.header": "Escojer grupo de mapas",
"pre.pool.banned": "Prohibidos",
"pre.pool.tiebreaker.short": "Desempate",
"pre.sub.prompt": "¿No tienes equipo en mente para este evento? También puedes unirte a la lista de substitutos.",
"bracket.type.DE_WINNERS": "Cuadro de ganadores",
"bracket.type.DE_LOSERS": "Cuadro de perdedores",
"bracket.type.SE": "Cuadro",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Informations",
"tabs.teams": "Équipes ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registration",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.results": "",
"tabs.streams": "Diffusions ({{count}})",
"tabs.subs": "Remplaçants",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Brackets",
"nav.register": "",
"nav.teams": "Équipes ({{count}})",
"nav.streams": "Diffusions ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Remplaçants",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Complétez ces étapes pour jouer",
"pre.steps.name": "Nom de l'équipe",
"pre.steps.roster": "Participants",
@@ -35,7 +50,6 @@
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
"pre.sub.prompt": "Pas d'équipe en tête pour cet évenement ? Vous pouvez aussi rejoindre la liste des remplaçants.",
"bracket.type.DE_WINNERS": "Manche des gagnants",
"bracket.type.DE_LOSERS": "Manche des perdants",
"bracket.type.SE": "Manche",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Informations",
"tabs.teams": "Équipes ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registration",
"tabs.brackets": "Brackets",
"tabs.seeds": "Seeds",
"tabs.results": "Resultats",
"tabs.streams": "Diffusions ({{count}})",
"tabs.subs": "Remplaçants",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Brackets",
"nav.register": "",
"nav.teams": "Équipes ({{count}})",
"nav.streams": "Diffusions ({{count}})",
"nav.results": "Resultats",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Remplaçants",
"nav.divisions": "",
"nav.seeds": "Seeds",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Complétez ces étapes pour jouer",
"pre.steps.name": "Nom de l'équipe",
"pre.steps.roster": "Participants",
@@ -35,7 +50,6 @@
"pre.pool.header": "Sélection de stage",
"pre.pool.banned": "Bannis",
"pre.pool.tiebreaker.short": "Manche décisive",
"pre.sub.prompt": "Pas d'équipe en tête pour cet évenement ? Vous pouvez aussi rejoindre la liste des remplaçants.",
"bracket.type.DE_WINNERS": "Manche des gagnants",
"bracket.type.DE_LOSERS": "Manche des perdants",
"bracket.type.SE": "Manche",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "מידע",
"tabs.teams": "צוותים ({{count}})",
"tabs.admin": נהל",
"tabs.register": "הרשמה",
"tabs.brackets": "מערכים",
"tabs.seeds": "דירוג",
"tabs.results": "",
"tabs.streams": "שידורים חיים ({{count}})",
"tabs.subs": "מחליפים",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": ערכים",
"nav.register": "",
"nav.teams": "צוותים ({{count}})",
"nav.streams": "שידורים חיים ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "מחליפים",
"nav.divisions": "",
"nav.seeds": "דירוג",
"nav.admin": "מנהל",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "השלימו את השלבים הבאים כדי לשחק",
"pre.steps.name": "שם קבוצה",
"pre.steps.roster": "צוות מלא",
@@ -35,7 +50,6 @@
"pre.pool.header": "בחרו מאגר מפות",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
"pre.sub.prompt": "אין לכם קבוצה מראש לאירוע? אתם יכולים להצטרף לרשימת הממלאי מקום.",
"bracket.type.DE_WINNERS": "סיבוב מנצחים",
"bracket.type.DE_LOSERS": "סיבוב מפסידים",
"bracket.type.SE": "סיבוב",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Squadre ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Registra",
"tabs.brackets": "Bracket",
"tabs.seeds": "Seed",
"tabs.results": "Resultati",
"tabs.streams": "Stream ({{count}})",
"tabs.subs": "Sub",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Bracket",
"nav.register": "",
"nav.teams": "Squadre ({{count}})",
"nav.streams": "Stream ({{count}})",
"nav.results": "Resultati",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Sub",
"nav.divisions": "",
"nav.seeds": "Seed",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Completa questi passaggi per giocare",
"pre.steps.name": "Nome team",
"pre.steps.roster": "Roster completo",
@@ -35,7 +50,6 @@
"pre.pool.header": "Scegli pool mappe",
"pre.pool.banned": "Banneta",
"pre.pool.tiebreaker.short": "Spareggio",
"pre.sub.prompt": "Non hai nessun team in mente per questo evento? Puoi anche unirti alla lista dei sub.",
"bracket.type.DE_WINNERS": "Round Vincitori",
"bracket.type.DE_LOSERS": "Round Perdenti",
"bracket.type.SE": "Round",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "情報",
"tabs.teams": "チーム ({{count}})",
"tabs.admin": "管理",
"tabs.register": "登録",
"tabs.brackets": "ブラケット",
"tabs.seeds": "シード",
"tabs.results": "結果",
"tabs.streams": "配信 ({{count}})",
"tabs.subs": "サブ",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "ブラケット",
"nav.register": "",
"nav.teams": "チーム ({{count}})",
"nav.streams": "配信 ({{count}})",
"nav.results": "結果",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "サブ",
"nav.divisions": "",
"nav.seeds": "シード",
"nav.admin": "管理",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "トーナメント参加のために下記を入力してください",
"pre.steps.name": "チーム名",
"pre.steps.roster": "全プレイヤー",
@@ -35,7 +50,6 @@
"pre.pool.header": "マッププールを選択する",
"pre.pool.banned": "禁止",
"pre.pool.tiebreaker.short": "タイブレイカー",
"pre.sub.prompt": "このイベントで参加したいチームが見当たらない場合は、サブで参加することもできます。",
"bracket.type.DE_WINNERS": "勝者ラウンド",
"bracket.type.DE_LOSERS": "敗者ラウンド",
"bracket.type.SE": "ラウンド",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "",
"tabs.teams": "",
"tabs.admin": "",
"tabs.register": "",
"tabs.brackets": "",
"tabs.seeds": "",
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "",
"nav.register": "",
"nav.teams": "",
"nav.streams": "",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",
@@ -35,7 +50,6 @@
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
"pre.sub.prompt": "",
"bracket.type.DE_WINNERS": "",
"bracket.type.DE_LOSERS": "",
"bracket.type.SE": "",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "",
"tabs.teams": "",
"tabs.admin": "",
"tabs.register": "",
"tabs.brackets": "",
"tabs.seeds": "",
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "",
"nav.register": "",
"nav.teams": "",
"nav.streams": "",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",
@@ -35,7 +50,6 @@
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
"pre.sub.prompt": "",
"bracket.type.DE_WINNERS": "",
"bracket.type.DE_LOSERS": "",
"bracket.type.SE": "",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Drużyny ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "",
"tabs.brackets": "",
"tabs.seeds": "",
"tabs.results": "",
"tabs.streams": "",
"tabs.subs": "",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "",
"nav.register": "",
"nav.teams": "Drużyny ({{count}})",
"nav.streams": "",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "",
"nav.divisions": "",
"nav.seeds": "",
"nav.admin": "Admin",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "",
"pre.steps.name": "",
"pre.steps.roster": "",
@@ -35,7 +50,6 @@
"pre.pool.header": "",
"pre.pool.banned": "",
"pre.pool.tiebreaker.short": "",
"pre.sub.prompt": "",
"bracket.type.DE_WINNERS": "Runda Zwycięzców",
"bracket.type.DE_LOSERS": "Runda Przegranych",
"bracket.type.SE": "Runda",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Info",
"tabs.teams": "Times ({{count}})",
"tabs.admin": "Administrador",
"tabs.register": "Registrar",
"tabs.brackets": "Brackets",
"tabs.seeds": "Sementes",
"tabs.results": "",
"tabs.streams": "Transmissões ({{count}})",
"tabs.subs": "Inscritos",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Brackets",
"nav.register": "",
"nav.teams": "Times ({{count}})",
"nav.streams": "Transmissões ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Inscritos",
"nav.divisions": "",
"nav.seeds": "Sementes",
"nav.admin": "Administrador",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Complete esses passos para jogar",
"pre.steps.name": "Nome do time",
"pre.steps.roster": "Lista de membros completa",
@@ -35,7 +50,6 @@
"pre.pool.header": "Escolher seleção de mapas",
"pre.pool.banned": "Banido",
"pre.pool.tiebreaker.short": "Desempate",
"pre.sub.prompt": "Não tem um time em mente para esse evento? Você também pode se juntar à lista de substitutos.",
"bracket.type.DE_WINNERS": "Round dos Vencedores",
"bracket.type.DE_LOSERS": "Round dos Perdedores",
"bracket.type.SE": "Round",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "Информация",
"tabs.teams": "Команды: ({{count}})",
"tabs.admin": "Администратор",
"tabs.register": "Регистрация",
"tabs.brackets": "Сетки",
"tabs.seeds": "Семена",
"tabs.results": "Результаты",
"tabs.streams": "Трансляции: ({{count}})",
"tabs.subs": "Запасные",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "Сетки",
"nav.register": "",
"nav.teams": "Команды: ({{count}})",
"nav.streams": "Трансляции: ({{count}})",
"nav.results": "Результаты",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "Запасные",
"nav.divisions": "",
"nav.seeds": "Семена",
"nav.admin": "Администратор",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "Завершите следующие шаги, чтобы закончить регистрацию",
"pre.steps.name": "Имя команды",
"pre.steps.roster": "Полный состав",
@@ -35,7 +50,6 @@
"pre.pool.header": "Выберите пул арен",
"pre.pool.banned": "Запрещено",
"pre.pool.tiebreaker.short": "Тайбрейк",
"pre.sub.prompt": "Нет команды? Запишитесь в качестве запасного для данного турнира.",
"bracket.type.DE_WINNERS": "Раунд победителей",
"bracket.type.DE_LOSERS": "Раунд проигравших",
"bracket.type.SE": "Раунд",

View File

@@ -1,14 +1,29 @@
{
"tabs.info": "比赛信息",
"tabs.teams": "参赛队伍 ({{count}})",
"tabs.admin": "管理",
"tabs.register": "报名",
"tabs.brackets": "对战表",
"tabs.seeds": "种子",
"tabs.results": "",
"tabs.streams": "直播 ({{count}})",
"tabs.subs": "替补",
"tabs.looking": "",
"nav.label": "",
"nav.moreItems": "",
"nav.brackets": "对战表",
"nav.register": "",
"nav.teams": "参赛队伍 ({{count}})",
"nav.streams": "直播 ({{count}})",
"nav.results": "",
"nav.rules": "",
"nav.looking": "",
"nav.subs": "替补",
"nav.divisions": "",
"nav.seeds": "种子",
"nav.admin": "管理",
"findTeam": "",
"registerNow": "",
"fact.format": "",
"fact.bracket": "",
"fact.modes": "",
"fact.tier": "",
"fact.tier.est": "",
"fact.ranked": "",
"fact.ranked.yes": "",
"fact.ranked.yesWithSeason": "",
"fact.ranked.no": "",
"fact.teamSize": "",
"pre.steps.header": "参加比赛需完成以下步骤",
"pre.steps.name": "队伍名称",
"pre.steps.roster": "完整阵容",
@@ -35,7 +50,6 @@
"pre.pool.header": "选择地图池",
"pre.pool.banned": "禁止",
"pre.pool.tiebreaker.short": "决胜局",
"pre.sub.prompt": "没有队伍?您也可以加入替补列表。",
"bracket.type.DE_WINNERS": "胜者组 Round",
"bracket.type.DE_LOSERS": "败者组 Round",
"bracket.type.SE": "单败制 Round",