mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-23 03:26:16 -05:00
Initial
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
59
app/features/tournament/components/FactCard.module.css
Normal file
59
app/features/tournament/components/FactCard.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
41
app/features/tournament/components/FactCard.tsx
Normal file
41
app/features/tournament/components/FactCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
app/features/tournament/components/RegistrationActions.tsx
Normal file
38
app/features/tournament/components/RegistrationActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
210
app/features/tournament/components/TournamentHeader.tsx
Normal file
210
app/features/tournament/components/TournamentHeader.tsx
Normal 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")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
149
app/features/tournament/components/TournamentNav.module.css
Normal file
149
app/features/tournament/components/TournamentNav.module.css
Normal 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);
|
||||
}
|
||||
351
app/features/tournament/components/TournamentNav.tsx
Normal file
351
app/features/tournament/components/TournamentNav.tsx
Normal 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 };
|
||||
}
|
||||
102
app/features/tournament/core/Tournament.test.ts
Normal file
102
app/features/tournament/core/Tournament.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
78
app/features/tournament/core/Tournament.ts
Normal file
78
app/features/tournament/core/Tournament.ts
Normal 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(" → ");
|
||||
}
|
||||
24
app/features/tournament/loaders/to.$id.info.server.ts
Normal file
24
app/features/tournament/loaders/to.$id.info.server.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
33
app/features/tournament/routes/to.$id.info.module.css
Normal file
33
app/features/tournament/routes/to.$id.info.module.css
Normal 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;
|
||||
}
|
||||
117
app/features/tournament/routes/to.$id.info.tsx
Normal file
117
app/features/tournament/routes/to.$id.info.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
77
app/features/tournament/routes/to.$id.rules.tsx
Normal file
77
app/features/tournament/routes/to.$id.rules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -524,7 +524,7 @@
|
||||
|
||||
.standingsDivider {
|
||||
width: 5px;
|
||||
background-color: var(--color-bg-high);
|
||||
background-color: var(--color-border-high);
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "סיבוב",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ラウンド",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Раунд",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user