mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 11:46:09 -05:00
Improve register page UI
This commit is contained in:
10
app/components/InviteLinkInput.module.css
Normal file
10
app/components/InviteLinkInput.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
|
||||
& input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
45
app/components/InviteLinkInput.tsx
Normal file
45
app/components/InviteLinkInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Check, Clipboard } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { Label } from "~/components/Label";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import styles from "./InviteLinkInput.module.css";
|
||||
|
||||
/** A labeled read-only invite link with a copy to clipboard button. */
|
||||
export function InviteLinkInput({
|
||||
link,
|
||||
label,
|
||||
}: {
|
||||
link: string;
|
||||
/** Overrides the default "Invite link" label. */
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const id = React.useId();
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={id}>{label ?? t("common:inviteLink")}</Label>
|
||||
<div className={styles.row}>
|
||||
<input
|
||||
type="text"
|
||||
value={link}
|
||||
readOnly
|
||||
id={id}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
data-testid="invite-link-input"
|
||||
/>
|
||||
<SendouButton
|
||||
shape="square"
|
||||
variant={copySuccess ? "outlined-success" : "outlined"}
|
||||
onPress={() => copyToClipboard(link)}
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
aria-label={t("common:actions.copyToClipboard")}
|
||||
data-testid="copy-invite-link-button"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,11 @@
|
||||
&[data-placeholder] {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
/* two-line items render only their label line inside the trigger */
|
||||
& [slot="description"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
gap: var(--s-1-5);
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
|
||||
&[data-focus-visible],
|
||||
&[aria-expanded="true"] {
|
||||
@@ -44,6 +45,9 @@
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
/* virtualized lists size from their container, so the popover cannot size from content */
|
||||
min-width: var(--trigger-width);
|
||||
}
|
||||
|
||||
.listBox {
|
||||
@@ -51,6 +55,11 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.itemFocused {
|
||||
background-color: var(--color-bg-high);
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -116,7 +116,7 @@ export function SelectShellItem({
|
||||
<ListBoxItem
|
||||
{...rest}
|
||||
className={({ isFocused, isSelected }) =>
|
||||
clsx(className, {
|
||||
clsx(className, styles.item, {
|
||||
[styles.itemFocused]: isFocused,
|
||||
[styles.itemSelected]: isSelected,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-2-5);
|
||||
padding: var(--s-3);
|
||||
background-color: var(--color-bg-high);
|
||||
gap: var(--s-3);
|
||||
width: 100%;
|
||||
padding: var(--s-4);
|
||||
background-color: var(--color-bg);
|
||||
border: var(--border-style);
|
||||
border-radius: var(--radius-box);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
@@ -13,7 +15,8 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.headingWindow {
|
||||
.windowText {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-body);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
@@ -21,7 +24,7 @@
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1-5);
|
||||
gap: var(--s-2-5);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
@@ -29,22 +32,80 @@
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
gap: var(--s-2);
|
||||
min-width: 0;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
& > svg {
|
||||
flex-shrink: 0;
|
||||
.statusCircle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: color-mix(in oklch, var(--color-text) 8%, transparent);
|
||||
|
||||
&[data-status="available"] {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-success) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&[data-status="partial"] {
|
||||
background-color: color-mix(
|
||||
in oklch,
|
||||
var(--color-warning) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&[data-status="unavailable"],
|
||||
&[data-status="busy"] {
|
||||
background-color: color-mix(in oklch, var(--color-error) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.nameBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: var(--weight-semi);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secondaryName {
|
||||
font-size: var(--font-3xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-high);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trailing {
|
||||
margin-inline-start: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ranges {
|
||||
color: var(--color-text-high);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detailText {
|
||||
color: var(--color-text-high);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mutedText {
|
||||
@@ -96,6 +157,27 @@
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-full);
|
||||
flex-shrink: 0;
|
||||
|
||||
&[data-status="available"] {
|
||||
background-color: var(--color-success);
|
||||
}
|
||||
|
||||
&[data-status="partial"] {
|
||||
background-color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.subsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
CalendarX,
|
||||
Check,
|
||||
Clock,
|
||||
Ellipsis,
|
||||
EyeOff,
|
||||
Flag,
|
||||
HelpCircle,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
@@ -16,7 +18,7 @@ import type { TimeRange } from "../availability-types";
|
||||
import type { RegistrationAvailability } from "../core/RegistrationAvailability.server";
|
||||
import styles from "./RegistrationAvailabilityPanel.module.css";
|
||||
|
||||
interface PanelUser {
|
||||
export interface AvailabilityPanelUser {
|
||||
id: number;
|
||||
username: string;
|
||||
discordId: string;
|
||||
@@ -24,8 +26,24 @@ interface PanelUser {
|
||||
customAvatarUrl?: string | null;
|
||||
}
|
||||
|
||||
type PanelData = SerializeFrom<RegistrationAvailability>;
|
||||
type PanelEntry = NonNullable<PanelData["entries"]>[number];
|
||||
export type AvailabilityPanelData = SerializeFrom<RegistrationAvailability>;
|
||||
export type AvailabilityPanelEntry = NonNullable<
|
||||
AvailabilityPanelData["entries"]
|
||||
>[number];
|
||||
|
||||
export type AvailabilityRowStatus =
|
||||
| AvailabilityPanelEntry["availability"]["status"]
|
||||
/** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */
|
||||
| "hidden";
|
||||
|
||||
const STATUS_ORDER: Array<AvailabilityRowStatus> = [
|
||||
"available",
|
||||
"partial",
|
||||
"unavailable",
|
||||
"busy",
|
||||
"unknown",
|
||||
"hidden",
|
||||
];
|
||||
|
||||
/**
|
||||
* The tournament registration page's availability panel: how each member of
|
||||
@@ -37,19 +55,12 @@ export function RegistrationAvailabilityPanel({
|
||||
roster,
|
||||
subCandidates,
|
||||
}: {
|
||||
availability: PanelData;
|
||||
roster: Array<PanelUser>;
|
||||
availability: AvailabilityPanelData;
|
||||
roster: Array<AvailabilityPanelUser>;
|
||||
/** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */
|
||||
subCandidates: Array<PanelUser>;
|
||||
subCandidates: Array<AvailabilityPanelUser>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const { formatter: windowFormatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const { formatter: dateFormatter } = useDateTimeFormat({
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -77,84 +88,138 @@ export function RegistrationAvailabilityPanel({
|
||||
return status === "available" || status === "partial";
|
||||
});
|
||||
|
||||
if (roster.length === 0 && freeSubs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className={styles.panel}>
|
||||
<h4 className={styles.heading}>
|
||||
{t("schedule:registration.title")} ·{" "}
|
||||
<span className={styles.headingWindow}>
|
||||
{windowFormatter.formatRange(
|
||||
availability.window.startsAt,
|
||||
availability.window.endsAt,
|
||||
)}{" "}
|
||||
({t("schedule:registration.estimated")})
|
||||
</span>
|
||||
<AvailabilityWindowText window={availability.window} />
|
||||
</h4>
|
||||
<ul className={styles.rows}>
|
||||
{roster.map((user) => (
|
||||
<MemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<SummaryLine roster={roster} entryByUserId={entryByUserId} />
|
||||
{freeSubs.length > 0 ? (
|
||||
<div className={styles.subsSection}>
|
||||
<h5 className={styles.subsHeading}>
|
||||
{t("schedule:registration.friends")}
|
||||
</h5>
|
||||
{roster.length > 0 ? (
|
||||
<>
|
||||
<ul className={styles.rows}>
|
||||
{freeSubs.map((user) => (
|
||||
<MemberRow
|
||||
{roster.map((user) => (
|
||||
<AvailabilityMemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<AvailabilitySummary
|
||||
statuses={roster.map((user) =>
|
||||
availabilityRowStatus(entryByUserId.get(user.id)),
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{freeSubs.length > 0 ? (
|
||||
roster.length > 0 ? (
|
||||
<div className={styles.subsSection}>
|
||||
<h5 className={styles.subsHeading}>
|
||||
{t("schedule:registration.friends")}
|
||||
</h5>
|
||||
<ul className={styles.rows}>
|
||||
{freeSubs.map((user) => (
|
||||
<AvailabilityMemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<ul className={styles.rows}>
|
||||
{freeSubs.map((user) => (
|
||||
<AvailabilityMemberRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
entry={entryByUserId.get(user.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRow({ user, entry }: { user: PanelUser; entry?: PanelEntry }) {
|
||||
/**
|
||||
* One user's availability as a list row: status icon, avatar, name and the
|
||||
* availability detail. The registration page composes it with roster extras
|
||||
* (an in-game name line, a remove button).
|
||||
*/
|
||||
export function AvailabilityMemberRow({
|
||||
user,
|
||||
entry,
|
||||
showAvailability = true,
|
||||
primaryName,
|
||||
secondaryName,
|
||||
trailing,
|
||||
nameTestId,
|
||||
}: {
|
||||
user: AvailabilityPanelUser;
|
||||
entry?: AvailabilityPanelEntry;
|
||||
/** Set false when there is no availability data for the event (e.g. leagues), keeping just avatar + name. */
|
||||
showAvailability?: boolean;
|
||||
primaryName?: string;
|
||||
secondaryName?: string;
|
||||
trailing?: React.ReactNode;
|
||||
nameTestId?: string;
|
||||
}) {
|
||||
const status = availabilityRowStatus(entry);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={styles.row}
|
||||
data-testid={`availability-row-${user.id}`}
|
||||
data-status={rowStatus(entry)}
|
||||
data-status={showAvailability ? status : undefined}
|
||||
>
|
||||
<StatusIcon status={rowStatus(entry)} />
|
||||
{showAvailability ? <StatusIcon status={status} /> : null}
|
||||
<Avatar user={user} size="xxs" />
|
||||
<span className={styles.name}>{user.username}</span>
|
||||
<RowDetail entry={entry} />
|
||||
{entry?.notes.map((note) => (
|
||||
<span key={note} className={styles.note}>
|
||||
<Flag size={12} className={styles.noteFlag} /> {note}
|
||||
</span>
|
||||
))}
|
||||
<span className={styles.nameBlock} data-testid={nameTestId}>
|
||||
<span className={styles.name}>{primaryName ?? user.username}</span>
|
||||
{secondaryName ? (
|
||||
<span className={styles.secondaryName}>{secondaryName}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{showAvailability ? <AvailabilityRowDetail entry={entry} /> : null}
|
||||
{showAvailability
|
||||
? entry?.notes.map((note) => (
|
||||
<span key={note} className={styles.note}>
|
||||
<Flag size={12} className={styles.noteFlag} /> {note}
|
||||
</span>
|
||||
))
|
||||
: null}
|
||||
{trailing ? <span className={styles.trailing}>{trailing}</span> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
type RowStatus =
|
||||
| PanelEntry["availability"]["status"]
|
||||
/** On the roster, but their schedule is not visible to the viewer (neither a teammate nor a friend). */
|
||||
| "hidden";
|
||||
|
||||
function rowStatus(entry?: PanelEntry): RowStatus {
|
||||
/**
|
||||
* Resolves the shown status for a roster member; no entry at all means their
|
||||
* schedule is not visible to the viewer.
|
||||
*/
|
||||
export function availabilityRowStatus(
|
||||
entry?: AvailabilityPanelEntry,
|
||||
): AvailabilityRowStatus {
|
||||
return entry?.availability.status ?? "hidden";
|
||||
}
|
||||
|
||||
function RowDetail({ entry }: { entry?: PanelEntry }) {
|
||||
/** The availability detail text of one user: free ranges, a busy block or a muted explanation. */
|
||||
export function AvailabilityRowDetail({
|
||||
entry,
|
||||
}: {
|
||||
entry?: AvailabilityPanelEntry;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
// xxx: is this what we want?
|
||||
if (!entry) {
|
||||
return (
|
||||
<span className={styles.mutedText}>
|
||||
<span className={styles.detailText}>
|
||||
{t("schedule:registration.notVisible")}
|
||||
</span>
|
||||
);
|
||||
@@ -168,13 +233,13 @@ function RowDetail({ entry }: { entry?: PanelEntry }) {
|
||||
return <RangesText ranges={availability.ranges} />;
|
||||
case "unavailable":
|
||||
return (
|
||||
<span className={styles.mutedText}>
|
||||
<span className={styles.detailText}>
|
||||
{t("schedule:team.notAvailable")}
|
||||
</span>
|
||||
);
|
||||
case "unknown":
|
||||
return (
|
||||
<span className={styles.mutedText}>
|
||||
<span className={styles.detailText}>
|
||||
{t("schedule:team.noSchedule")}
|
||||
</span>
|
||||
);
|
||||
@@ -189,6 +254,79 @@ function RowDetail({ entry }: { entry?: PanelEntry }) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The event's estimated window as a localized time range, e.g. "Tue, Aug 25, 10:32 AM – 2:32 PM (estimated)". */
|
||||
export function AvailabilityWindowText({
|
||||
window,
|
||||
}: {
|
||||
window: NonNullable<AvailabilityPanelData["window"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
const { formatter } = useDateTimeFormat({
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={styles.windowText}>
|
||||
{formatter.formatRange(window.startsAt, window.endsAt)} (
|
||||
{t("schedule:registration.estimated")})
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Counts by status, e.g. "2 available · 1 partial · 1 out". */
|
||||
export function AvailabilitySummary({
|
||||
statuses,
|
||||
className,
|
||||
}: {
|
||||
statuses: Array<AvailabilityRowStatus>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
const counts = { available: 0, partial: 0, out: 0, unknown: 0 };
|
||||
for (const status of statuses) {
|
||||
if (status === "available") counts.available++;
|
||||
else if (status === "partial") counts.partial++;
|
||||
else if (status === "unavailable" || status === "busy") counts.out++;
|
||||
else counts.unknown++;
|
||||
}
|
||||
|
||||
const parts = (["available", "partial", "out", "unknown"] as const).flatMap(
|
||||
(key) =>
|
||||
counts[key] > 0
|
||||
? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={clsx(styles.summary, className)}>{parts.join(" · ")}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A green dot per available member and a yellow dot per partially available one; other statuses show no dot. */
|
||||
export function AvailabilityStatusDots({
|
||||
statuses,
|
||||
}: {
|
||||
statuses: Array<AvailabilityRowStatus>;
|
||||
}) {
|
||||
const shown = statuses
|
||||
.filter((status) => status === "available" || status === "partial")
|
||||
.sort((a, b) => STATUS_ORDER.indexOf(a) - STATUS_ORDER.indexOf(b));
|
||||
if (shown.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={styles.dots}>
|
||||
{shown.map((status, i) => (
|
||||
<span key={i} className={styles.dot} data-status={status} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
|
||||
const { formatter: timeFormatter } = useDateTimeFormat({
|
||||
hour: "numeric",
|
||||
@@ -209,47 +347,39 @@ function RangesText({ ranges }: { ranges: Array<TimeRange> }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: RowStatus }) {
|
||||
function StatusIcon({ status }: { status: AvailabilityRowStatus }) {
|
||||
return (
|
||||
<span className={styles.statusCircle} data-status={status}>
|
||||
{statusGlyph(status)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusGlyph(status: AvailabilityRowStatus) {
|
||||
switch (status) {
|
||||
case "available":
|
||||
return <Check size={16} className={styles.iconAvailable} />;
|
||||
return (
|
||||
<Check size={15} strokeWidth={3} className={styles.iconAvailable} />
|
||||
);
|
||||
case "partial":
|
||||
return <Clock size={14} className={styles.iconPartial} />;
|
||||
return <Clock size={13} strokeWidth={3} className={styles.iconPartial} />;
|
||||
case "unavailable":
|
||||
return <X size={16} className={styles.iconUnavailable} />;
|
||||
return <X size={15} strokeWidth={3} className={styles.iconUnavailable} />;
|
||||
case "busy":
|
||||
return <CalendarX size={14} className={styles.iconUnavailable} />;
|
||||
return (
|
||||
<CalendarX
|
||||
size={13}
|
||||
strokeWidth={3}
|
||||
className={styles.iconUnavailable}
|
||||
/>
|
||||
);
|
||||
case "unknown":
|
||||
return <HelpCircle size={14} className={styles.iconUnknown} />;
|
||||
return (
|
||||
<Ellipsis size={15} strokeWidth={3} className={styles.iconUnknown} />
|
||||
);
|
||||
case "hidden":
|
||||
return <EyeOff size={14} className={styles.iconUnknown} />;
|
||||
return (
|
||||
<EyeOff size={13} strokeWidth={3} className={styles.iconUnknown} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SummaryLine({
|
||||
roster,
|
||||
entryByUserId,
|
||||
}: {
|
||||
roster: Array<PanelUser>;
|
||||
entryByUserId: Map<number, PanelEntry>;
|
||||
}) {
|
||||
const { t } = useTranslation(["schedule"]);
|
||||
|
||||
const counts = { available: 0, partial: 0, out: 0, unknown: 0 };
|
||||
for (const user of roster) {
|
||||
const status = rowStatus(entryByUserId.get(user.id));
|
||||
if (status === "available") counts.available++;
|
||||
else if (status === "partial") counts.partial++;
|
||||
else if (status === "unavailable" || status === "busy") counts.out++;
|
||||
else counts.unknown++;
|
||||
}
|
||||
|
||||
const parts = (["available", "partial", "out", "unknown"] as const).flatMap(
|
||||
(key) =>
|
||||
counts[key] > 0
|
||||
? [t(`schedule:registration.summary.${key}`, { amount: counts[key] })]
|
||||
: [],
|
||||
);
|
||||
|
||||
return <div className={styles.summary}>{parts.join(" · ")}</div>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { sub } from "date-fns";
|
||||
import {
|
||||
Check,
|
||||
Clipboard,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Map as MapIcon,
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
SendouTabPanel,
|
||||
SendouTabs,
|
||||
} from "~/components/elements/Tabs";
|
||||
import { InviteLinkInput } from "~/components/InviteLinkInput";
|
||||
import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTopicRevalidation } from "~/features/chat/chat-hooks";
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
TournamentProvider,
|
||||
useTournament,
|
||||
} from "~/features/tournament/tournament-context";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
@@ -438,7 +437,6 @@ function MapPreparer({
|
||||
|
||||
function AddSubsPopOver() {
|
||||
const { t } = useTranslation(["common", "tournament"]);
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
const data = useLoaderData<TournamentBracketsLoaderData>();
|
||||
@@ -465,19 +463,7 @@ function AddSubsPopOver() {
|
||||
{subsAvailableToAdd > 0 ? (
|
||||
<>
|
||||
<Divider className="my-2" />
|
||||
<div>{t("tournament:actions.shareLink", { inviteLink })}</div>
|
||||
<div className="mt-2 flex justify-center">
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
onPress={() => copyToClipboard(inviteLink)}
|
||||
variant="minimal"
|
||||
className="tiny"
|
||||
data-testid="copy-invite-link-button"
|
||||
>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
<InviteLinkInput link={inviteLink} />
|
||||
</>
|
||||
) : null}
|
||||
</SubsPopover>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
padding: var(--s-4) var(--s-3);
|
||||
}
|
||||
|
||||
.sectionInputContainer {
|
||||
width: 16rem;
|
||||
.sectionForm {
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.sectionWarning {
|
||||
@@ -19,42 +21,104 @@
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.rosterGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 110px);
|
||||
gap: var(--s-4);
|
||||
margin-block-start: var(--s-2);
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
.teamOptionAvailability {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
|
||||
.rosterGridMemberName {
|
||||
max-width: 110px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.rosterMutedNote {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.missingPlayer {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
font-size: 32px;
|
||||
border-radius: 100%;
|
||||
border: var(--border-style-accent);
|
||||
.rosterRows {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > li {
|
||||
padding-block: var(--s-2);
|
||||
|
||||
& + li {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emptySlotRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.emptySlotRowOptional {
|
||||
color: var(--color-text-high);
|
||||
}
|
||||
|
||||
.emptySlotCircle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin: 0 auto;
|
||||
border-radius: var(--radius-full);
|
||||
border: var(--border-style-accent);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
.missingPlayerOptional {
|
||||
border: 2px dashed var(--color-text-accent);
|
||||
color: var(--color-text-accent);
|
||||
.emptySlotCircleOptional {
|
||||
border: var(--border-width) dashed var(--color-text-accent);
|
||||
}
|
||||
|
||||
.addMembers {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--s-3);
|
||||
}
|
||||
|
||||
.addMembersHeading {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--color-text-high);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.quickAddRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--s-2);
|
||||
}
|
||||
|
||||
.quickAddSelect {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quickAddItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.quickAddItemAvailability {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-body);
|
||||
}
|
||||
|
||||
@container (width >= 640px) {
|
||||
.section {
|
||||
margin: 0;
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-6) var(--s-5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import clsx from "clsx";
|
||||
import { AlertCircle, Check, Clipboard, X } from "lucide-react";
|
||||
import { AlertCircle, Check, UserRound, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Text } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher, useLoaderData } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { ActionButton } from "~/components/ActionButton";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { SendouPopover } from "~/components/elements/Popover";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { FriendCodePopover } from "~/components/FriendCodePopover";
|
||||
import { Label } from "~/components/Label";
|
||||
import { InviteLinkInput } from "~/components/InviteLinkInput";
|
||||
import { containerClassName } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { Config } from "~/config";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { RegistrationAvailabilityPanel } from "~/features/availability/components/RegistrationAvailabilityPanel";
|
||||
import {
|
||||
AvailabilityMemberRow,
|
||||
type AvailabilityPanelEntry,
|
||||
AvailabilityRowDetail,
|
||||
type AvailabilityRowStatus,
|
||||
AvailabilityStatusDots,
|
||||
AvailabilitySummary,
|
||||
AvailabilityWindowText,
|
||||
availabilityRowStatus,
|
||||
RegistrationAvailabilityPanel,
|
||||
} from "~/features/availability/components/RegistrationAvailabilityPanel";
|
||||
import { timezoneMiddleware } from "~/features/timezone/timezone-middleware.server";
|
||||
import {
|
||||
type CounterPickMapPool,
|
||||
@@ -33,7 +43,6 @@ import { FormField } from "~/form/FormField";
|
||||
import { SendouForm, useFormFieldContext } from "~/form/SendouForm";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
@@ -51,7 +60,6 @@ import {
|
||||
import {
|
||||
addPlayerSchema,
|
||||
checkInSchema,
|
||||
deleteTeamMemberSchema,
|
||||
updateMapPoolSchema,
|
||||
} from "../tournament-schemas";
|
||||
import type { Route } from "./+types/to.$id.register";
|
||||
@@ -59,6 +67,15 @@ import styles from "./to.$id.register.module.css";
|
||||
|
||||
export { action, loader };
|
||||
|
||||
const QUICK_ADD_STATUS_ORDER: Record<AvailabilityRowStatus, number> = {
|
||||
available: 0,
|
||||
partial: 1,
|
||||
unknown: 2,
|
||||
hidden: 3,
|
||||
busy: 4,
|
||||
unavailable: 5,
|
||||
};
|
||||
|
||||
export const middleware: Route.MiddlewareFunction[] = [timezoneMiddleware];
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
@@ -224,7 +241,6 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) {
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ownTeam ? <TournamentRosterAvailability ownTeam={ownTeam} /> : null}
|
||||
{tournament.isLeague &&
|
||||
tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? (
|
||||
<GoogleFormsLink />
|
||||
@@ -259,7 +275,6 @@ function ReadOnlyRegistrationForms() {
|
||||
members={team.members}
|
||||
/>
|
||||
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
|
||||
<TournamentRosterAvailability ownTeam={team} />
|
||||
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
|
||||
{tournament.teamsPrePickMaps ? (
|
||||
<TeamCounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
|
||||
@@ -544,7 +559,7 @@ function TeamInfo({
|
||||
<SendouForm
|
||||
schema={registerTeamFormSchema}
|
||||
defaultValues={defaultValues}
|
||||
className="stack md items-center"
|
||||
className={clsx("stack md", styles.sectionForm)}
|
||||
submitButtonText={t("common:actions.save")}
|
||||
submitButtonTestId="save-team-button"
|
||||
readOnly={readOnly}
|
||||
@@ -564,12 +579,8 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.sectionInputContainer}>
|
||||
<FormField name="pickUpName" />
|
||||
</div>
|
||||
<div className={styles.sectionInputContainer}>
|
||||
<FormField name="logo" />
|
||||
</div>
|
||||
<FormField name="pickUpName" />
|
||||
<FormField name="logo" />
|
||||
<FormField name="prefersNotToHost" />
|
||||
</>
|
||||
);
|
||||
@@ -577,31 +588,42 @@ function RegisterTeamFields({ readOnly = false }: { readOnly?: boolean }) {
|
||||
|
||||
const isLinked = Boolean(values.teamId);
|
||||
|
||||
const teamOptions = (data?.teams ?? []).map((team) => ({
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
}));
|
||||
const entryByUserId = availabilityEntryByUserId(data);
|
||||
|
||||
const teamOptions = (data?.teams ?? []).map((team) => {
|
||||
const statuses = (
|
||||
entryByUserId
|
||||
? teamMemberStatuses({ data, teamId: team.id, entryByUserId })
|
||||
: []
|
||||
).filter((status) => status === "available" || status === "partial");
|
||||
|
||||
return {
|
||||
value: String(team.id),
|
||||
label: team.name,
|
||||
description:
|
||||
statuses.length > 0 ? (
|
||||
<span className={styles.teamOptionAvailability}>
|
||||
<AvailabilityStatusDots statuses={statuses} />
|
||||
<AvailabilitySummary statuses={statuses} />
|
||||
</span>
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTeamSelect ? (
|
||||
<div className={styles.sectionInputContainer}>
|
||||
<FormField name="teamId" options={teamOptions} />
|
||||
</div>
|
||||
<FormField name="teamId" options={teamOptions} />
|
||||
) : null}
|
||||
{!data?.ownTeam ? <SelectedTeamAvailability /> : null}
|
||||
{!isLinked ? (
|
||||
<>
|
||||
<div className={styles.sectionInputContainer}>
|
||||
<FormField
|
||||
name="pickUpName"
|
||||
disabled={!tournament.registrationOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.sectionInputContainer}>
|
||||
<FormField name="logo" />
|
||||
</div>
|
||||
<FormField
|
||||
name="pickUpName"
|
||||
disabled={!tournament.registrationOpen}
|
||||
/>
|
||||
<FormField name="logo" />
|
||||
</>
|
||||
) : null}
|
||||
<FormField name="prefersNotToHost" />
|
||||
@@ -667,8 +689,11 @@ function FillRoster({
|
||||
}) {
|
||||
const data = useLoaderData<TournamentRegisterPageLoader>();
|
||||
const tournament = useTournament();
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
const { t } = useTranslation(["common", "tournament"]);
|
||||
const { t } = useTranslation(["common", "tournament", "schedule"]);
|
||||
const { formatter: dateFormatter } = useDateTimeFormat({
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
@@ -687,16 +712,19 @@ function FillRoster({
|
||||
0,
|
||||
);
|
||||
|
||||
const showDeleteMemberSection =
|
||||
const canRemoveMembers =
|
||||
!readOnly &&
|
||||
!tournament.isInvitational &&
|
||||
((!ownTeamCheckedIn && ownTeamMembers.length > 1) ||
|
||||
(ownTeamCheckedIn &&
|
||||
ownTeamMembers.length > tournament.minMembersPerTeam));
|
||||
|
||||
const playersAvailableToDirectlyAdd = (() => {
|
||||
const quickAddPlayers = (() => {
|
||||
if (readOnly) return [];
|
||||
return (data?.friendPlayers?.friends ?? []).filter((user) => {
|
||||
return R.uniqueBy(
|
||||
data?.friendPlayers?.friends ?? [],
|
||||
(friend) => friend.id,
|
||||
).filter((user) => {
|
||||
const isNotInTeam = tournament.ctx.teams.every(
|
||||
(team) => !team.memberUserIds.includes(user.id),
|
||||
);
|
||||
@@ -711,89 +739,105 @@ function FillRoster({
|
||||
const teamIsFull = ownTeamMembers.length >= tournament.maxMembersPerTeam;
|
||||
const canAddMembers = !teamIsFull && tournament.registrationOpen && !readOnly;
|
||||
|
||||
const availability = data?.availability;
|
||||
const entryByUserId = availabilityEntryByUserId(data);
|
||||
const requireInGameNames = tournament.ctx.settings.requireInGameNames;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className={styles.sectionHeader}>
|
||||
2. {t("tournament:pre.roster.header")}
|
||||
</h3>
|
||||
<section className={clsx(styles.section, "stack lg items-center")}>
|
||||
{playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? (
|
||||
<>
|
||||
<DirectlyAddPlayerSelect
|
||||
players={playersAvailableToDirectlyAdd}
|
||||
teams={data!.friendPlayers?.teams ?? []}
|
||||
/>
|
||||
<Divider className="text-uppercase">{t("common:or")}</Divider>
|
||||
</>
|
||||
<div className="stack xs horizontal justify-between items-end flex-wrap">
|
||||
<h3 className={styles.sectionHeader}>
|
||||
2. {t("tournament:pre.roster.header")}
|
||||
</h3>
|
||||
{availability?.window ? (
|
||||
<AvailabilityWindowText window={availability.window} />
|
||||
) : null}
|
||||
{canAddMembers ? (
|
||||
<div className="stack md items-center">
|
||||
<div className="text-center text-sm">
|
||||
{t("tournament:actions.shareLink", { inviteLink })}
|
||||
</div>
|
||||
<div>
|
||||
<SendouButton
|
||||
size="small"
|
||||
icon={copySuccess ? <Check /> : <Clipboard />}
|
||||
onPress={() => copyToClipboard(inviteLink)}
|
||||
variant="outlined"
|
||||
>
|
||||
{t("common:actions.copyToClipboard")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
</div>
|
||||
<section className={clsx(styles.section, "stack sm")}>
|
||||
{availability?.beyondHorizon ? (
|
||||
<div className={styles.rosterMutedNote}>
|
||||
{t("schedule:registration.beyondHorizon", {
|
||||
date: dateFormatter.format(availability.beyondHorizon.opensAt),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.rosterGrid}>
|
||||
{ownTeamMembers.map((member, i) => {
|
||||
return (
|
||||
<div
|
||||
key={member.userId}
|
||||
className="stack sm items-center text-sm"
|
||||
data-testid={`member-num-${i + 1}`}
|
||||
>
|
||||
<Avatar size="xsm" user={member} />
|
||||
{tournament.ctx.settings.requireInGameNames ? (
|
||||
<div className={styles.rosterGridMemberName}>
|
||||
<div className="text-center">
|
||||
{member.inGameName ?? member.username}
|
||||
</div>
|
||||
{member.inGameName ? (
|
||||
<div className="text-lighter text-xs font-bold text-center">
|
||||
{member.username}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.rosterGridMemberName}>
|
||||
{member.username}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{new Array(missingMembers).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div key={i} className={styles.missingPlayer}>
|
||||
?
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{new Array(optionalMembers).fill(null).map((_, i) => {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
<ul className={styles.rosterRows}>
|
||||
{ownTeamMembers.map((member, i) => (
|
||||
<AvailabilityMemberRow
|
||||
key={member.userId}
|
||||
user={{
|
||||
id: member.userId,
|
||||
username: member.username,
|
||||
discordId: member.discordId,
|
||||
discordAvatar: member.discordAvatar,
|
||||
customAvatarUrl: member.customAvatarUrl,
|
||||
}}
|
||||
entry={entryByUserId?.get(member.userId)}
|
||||
showAvailability={Boolean(entryByUserId)}
|
||||
primaryName={
|
||||
requireInGameNames
|
||||
? (member.inGameName ?? member.username)
|
||||
: member.username
|
||||
}
|
||||
secondaryName={
|
||||
requireInGameNames && member.inGameName
|
||||
? member.username
|
||||
: undefined
|
||||
}
|
||||
nameTestId={`member-num-${i + 1}`}
|
||||
trailing={
|
||||
canRemoveMembers && member.role !== "OWNER" ? (
|
||||
<RemoveMemberButton member={member} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: missingMembers }).map((_, i) => (
|
||||
<li key={`required-${i}`} className={styles.emptySlotRow}>
|
||||
<span className={styles.emptySlotCircle}>
|
||||
<UserRound size={14} strokeWidth={3} />
|
||||
</span>
|
||||
{t("tournament:pre.roster.emptySlot")}
|
||||
</li>
|
||||
))}
|
||||
{Array.from({ length: optionalMembers }).map((_, i) => (
|
||||
<li
|
||||
key={`optional-${i}`}
|
||||
className={clsx(styles.emptySlotRow, styles.emptySlotRowOptional)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
styles.missingPlayer,
|
||||
styles.missingPlayerOptional,
|
||||
styles.emptySlotCircle,
|
||||
styles.emptySlotCircleOptional,
|
||||
)}
|
||||
>
|
||||
?
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{showDeleteMemberSection ? (
|
||||
<DeleteMember members={ownTeamMembers} />
|
||||
<UserRound size={14} strokeWidth={3} />
|
||||
</span>
|
||||
{t("tournament:pre.roster.emptySlot.optional")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{entryByUserId ? (
|
||||
<AvailabilitySummary
|
||||
statuses={ownTeamMembers.map((member) =>
|
||||
availabilityRowStatus(entryByUserId.get(member.userId)),
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{canAddMembers ? (
|
||||
<div className={clsx(styles.addMembers, "stack md")}>
|
||||
<h4 className={styles.addMembersHeading}>
|
||||
{t("tournament:pre.roster.addMembers")}
|
||||
</h4>
|
||||
{quickAddPlayers.length > 0 ? (
|
||||
<QuickAddPlayers
|
||||
key={quickAddPlayers.map((player) => player.id).join(",")}
|
||||
players={quickAddPlayers}
|
||||
entryByUserId={entryByUserId}
|
||||
/>
|
||||
) : null}
|
||||
<InviteLinkInput link={inviteLink} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{tournament.ctx.settings.requireInGameNames ? (
|
||||
@@ -816,111 +860,119 @@ function FillRoster({
|
||||
);
|
||||
}
|
||||
|
||||
function DirectlyAddPlayerSelect({
|
||||
function QuickAddPlayers({
|
||||
players,
|
||||
teams,
|
||||
entryByUserId,
|
||||
}: {
|
||||
players: { id: number; username: string; teamId?: number }[];
|
||||
teams: { id: number; name: string }[];
|
||||
players: Array<{ id: number; username: string }>;
|
||||
entryByUserId: Map<number, AvailabilityPanelEntry> | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament", "common"]);
|
||||
const fetcher = useFetcher();
|
||||
const id = React.useId();
|
||||
|
||||
const othersOptions = players
|
||||
.filter((player) => !player.teamId)
|
||||
.map((player) => {
|
||||
return (
|
||||
<option key={player.id} value={player.id}>
|
||||
{player.username}
|
||||
</option>
|
||||
);
|
||||
});
|
||||
const sortedPlayers = entryByUserId
|
||||
? R.sortBy(
|
||||
players,
|
||||
(player) =>
|
||||
QUICK_ADD_STATUS_ORDER[
|
||||
availabilityRowStatus(entryByUserId.get(player.id))
|
||||
],
|
||||
)
|
||||
: players;
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<number | null>(
|
||||
sortedPlayers[0]?.id ?? null,
|
||||
);
|
||||
|
||||
// xxx: split team, pickup. quick button to add all player roles for team
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack horizontal sm items-end">
|
||||
<div>
|
||||
<Label htmlFor={id}>
|
||||
{t("tournament:pre.roster.addFriend.header")}
|
||||
</Label>
|
||||
<select id={id} name="userId">
|
||||
{teams.map((team) => {
|
||||
return (
|
||||
<optgroup label={team.name} key={team.id}>
|
||||
{players
|
||||
.filter((player) => player.teamId === team.id)
|
||||
.map((player) => {
|
||||
return (
|
||||
<option key={player.id} value={player.id}>
|
||||
{player.username}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
{teams && teams.length > 0 ? (
|
||||
<optgroup label={t("tournament:pre.roster.addFriend.others")}>
|
||||
{othersOptions}
|
||||
</optgroup>
|
||||
) : (
|
||||
othersOptions
|
||||
<fetcher.Form method="post">
|
||||
<div className={styles.quickAddRow}>
|
||||
<SendouSelect
|
||||
label={t("tournament:pre.roster.quickAdd")}
|
||||
items={sortedPlayers}
|
||||
selectedKey={selectedUserId}
|
||||
onSelectionChange={(key) => setSelectedUserId(key as number | null)}
|
||||
estimatedRowHeight={entryByUserId ? 52 : undefined}
|
||||
className={styles.quickAddSelect}
|
||||
data-testid="quick-add-select"
|
||||
>
|
||||
{(player) => (
|
||||
<SendouSelectItem
|
||||
id={player.id}
|
||||
textValue={player.username}
|
||||
data-testid={`availability-row-${player.id}`}
|
||||
data-status={
|
||||
entryByUserId
|
||||
? availabilityRowStatus(entryByUserId.get(player.id))
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{entryByUserId ? (
|
||||
<span className={styles.quickAddItem}>
|
||||
<Text slot="label">{player.username}</Text>
|
||||
<Text slot="description">
|
||||
<span className={styles.quickAddItemAvailability}>
|
||||
<AvailabilityStatusDots
|
||||
statuses={[
|
||||
availabilityRowStatus(entryByUserId.get(player.id)),
|
||||
]}
|
||||
/>
|
||||
<AvailabilityRowDetail
|
||||
entry={entryByUserId.get(player.id)}
|
||||
/>
|
||||
</span>
|
||||
</Text>
|
||||
</span>
|
||||
) : (
|
||||
player.username
|
||||
)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</select>
|
||||
</SendouSelect>
|
||||
{selectedUserId ? (
|
||||
<input type="hidden" name="userId" value={selectedUserId} />
|
||||
) : null}
|
||||
<SubmitButton
|
||||
schema={addPlayerSchema}
|
||||
_action="ADD_PLAYER"
|
||||
state={fetcher.state}
|
||||
testId="add-player-button"
|
||||
isDisabled={!selectedUserId}
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
<SubmitButton
|
||||
schema={addPlayerSchema}
|
||||
_action="ADD_PLAYER"
|
||||
state={fetcher.state}
|
||||
testId="add-player-button"
|
||||
>
|
||||
{t("common:actions.add")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) {
|
||||
function RemoveMemberButton({
|
||||
member,
|
||||
}: {
|
||||
member: TournamentTeamFull["members"][number];
|
||||
}) {
|
||||
const { t } = useTranslation(["tournament", "common"]);
|
||||
const id = React.useId();
|
||||
const fetcher = useFetcher();
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("tournament:pre.roster.remove.confirm", {
|
||||
name: member.username,
|
||||
})}
|
||||
submitButtonText={t("common:actions.remove")}
|
||||
fields={[
|
||||
["_action", "DELETE_TEAM_MEMBER"],
|
||||
["userId", member.userId],
|
||||
]}
|
||||
>
|
||||
<SendouButton
|
||||
size="small"
|
||||
variant="minimal-destructive"
|
||||
onPress={() => setExpanded(true)}
|
||||
>
|
||||
{t("tournament:pre.roster.delete.button")}
|
||||
</SendouButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post">
|
||||
<Label htmlFor={id}>{t("tournament:pre.roster.delete.header")}</Label>
|
||||
<div className="stack md horizontal">
|
||||
<select name="userId" id={id}>
|
||||
{members
|
||||
.filter((member) => member.role !== "OWNER")
|
||||
.map((member) => (
|
||||
<option key={member.userId} value={member.userId}>
|
||||
{member.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SubmitButton
|
||||
state={fetcher.state}
|
||||
schema={deleteTeamMemberSchema}
|
||||
_action="DELETE_TEAM_MEMBER"
|
||||
variant="minimal-destructive"
|
||||
>
|
||||
{t("common:actions.delete")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
icon={<X />}
|
||||
aria-label={t("common:actions.remove")}
|
||||
testId={`remove-member-${member.userId}`}
|
||||
/>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -976,51 +1028,26 @@ function TeamCounterPickMapPoolPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentRosterAvailability({
|
||||
ownTeam,
|
||||
}: {
|
||||
ownTeam: TournamentTeamFull;
|
||||
}) {
|
||||
function SelectedTeamAvailability() {
|
||||
const data = useLoaderData<TournamentRegisterPageLoader>();
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
const { values } = useFormFieldContext();
|
||||
|
||||
const availability = data?.availability;
|
||||
if (!availability) return null;
|
||||
|
||||
const roster = ownTeam.members.map((member) => ({
|
||||
id: member.userId,
|
||||
username: member.username,
|
||||
discordId: member.discordId,
|
||||
discordAvatar: member.discordAvatar,
|
||||
customAvatarUrl: member.customAvatarUrl,
|
||||
}));
|
||||
|
||||
return (
|
||||
<RegistrationAvailabilityPanel
|
||||
availability={availability}
|
||||
roster={roster}
|
||||
subCandidates={subCandidates({
|
||||
data,
|
||||
tournament,
|
||||
rosterUserIds: roster.map((user) => user.id),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectedTeamAvailability() {
|
||||
const data = useLoaderData<TournamentRegisterPageLoader>();
|
||||
const tournament = useTournament();
|
||||
const { values } = useFormFieldContext();
|
||||
|
||||
const availability = data?.availability;
|
||||
const teamId = values.teamId ? Number(values.teamId) : null;
|
||||
if (!availability || !teamId) return null;
|
||||
|
||||
const roster = (data?.friendPlayers?.friends ?? [])
|
||||
.filter((friend) => friend.teamId === teamId)
|
||||
.map(panelUser);
|
||||
if (roster.length === 0) return null;
|
||||
// with a team selected the panel shows its full roster; signing up as a
|
||||
// pickup it instead lists everyone the viewer could recruit (teammates and
|
||||
// friends), which the panel keeps to those actually free during the event
|
||||
const roster = teamId
|
||||
? (data?.friendPlayers?.friends ?? [])
|
||||
.filter((friend) => friend.teamId === teamId)
|
||||
.map(panelUser)
|
||||
: [];
|
||||
if (teamId && roster.length === 0) return null;
|
||||
|
||||
return (
|
||||
<RegistrationAvailabilityPanel
|
||||
@@ -1029,7 +1056,11 @@ function SelectedTeamAvailability() {
|
||||
subCandidates={subCandidates({
|
||||
data,
|
||||
tournament,
|
||||
rosterUserIds: roster.map((user) => user.id),
|
||||
rosterUserIds: teamId
|
||||
? roster.map((rosterUser) => rosterUser.id)
|
||||
: user
|
||||
? [user.id]
|
||||
: [],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
@@ -1051,6 +1082,29 @@ function panelUser(user: {
|
||||
};
|
||||
}
|
||||
|
||||
function availabilityEntryByUserId(
|
||||
data: ReturnType<typeof useLoaderData<TournamentRegisterPageLoader>>,
|
||||
) {
|
||||
const availability = data?.availability;
|
||||
if (!availability || availability.beyondHorizon) return null;
|
||||
|
||||
return new Map(availability.entries.map((entry) => [entry.userId, entry]));
|
||||
}
|
||||
|
||||
function teamMemberStatuses({
|
||||
data,
|
||||
teamId,
|
||||
entryByUserId,
|
||||
}: {
|
||||
data: ReturnType<typeof useLoaderData<TournamentRegisterPageLoader>>;
|
||||
teamId: number;
|
||||
entryByUserId: Map<number, AvailabilityPanelEntry>;
|
||||
}): Array<AvailabilityRowStatus> {
|
||||
return (data?.friendPlayers?.friends ?? [])
|
||||
.filter((friend) => friend.teamId === teamId)
|
||||
.map((friend) => availabilityRowStatus(entryByUserId.get(friend.id)));
|
||||
}
|
||||
|
||||
function subCandidates({
|
||||
data,
|
||||
tournament,
|
||||
|
||||
@@ -289,6 +289,7 @@ export function FormField({
|
||||
items={selectOptions.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
}))}
|
||||
value={value as string | null}
|
||||
onChange={handleChange as (v: string | null) => void}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
.searchable {
|
||||
--select-width: 100%;
|
||||
}
|
||||
|
||||
.twoLineItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-0-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.itemDescription {
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-body);
|
||||
color: var(--color-text-high);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Text } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
|
||||
import type { FormFieldItems, FormFieldProps } from "../types";
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from "./FormFieldWrapper";
|
||||
import styles from "./SelectFormField.module.css";
|
||||
|
||||
const TWO_LINE_ROW_HEIGHT = 52;
|
||||
|
||||
type SelectFormFieldProps<V extends string> = Omit<
|
||||
FormFieldProps<"select">,
|
||||
"items" | "clearable" | "onBlur" | "name" | "searchable"
|
||||
@@ -56,12 +59,17 @@ export function SelectFormField<V extends string>({
|
||||
return {
|
||||
value: item.value,
|
||||
resolvedLabel,
|
||||
description: item.description,
|
||||
};
|
||||
});
|
||||
|
||||
if (searchable) {
|
||||
const hasDescriptions = itemsWithResolvedLabels.some(
|
||||
(item) => item.description,
|
||||
);
|
||||
|
||||
if (searchable || hasDescriptions) {
|
||||
return (
|
||||
<SearchableSelect
|
||||
<CustomSelect
|
||||
name={name}
|
||||
label={label}
|
||||
bottomText={bottomText}
|
||||
@@ -72,7 +80,7 @@ export function SelectFormField<V extends string>({
|
||||
onBlur={onBlur}
|
||||
clearable={clearable}
|
||||
disabled={disabled}
|
||||
searchPlaceholder={t("common:actions.search")}
|
||||
searchPlaceholder={searchable ? t("common:actions.search") : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,7 +121,7 @@ export function SelectFormField<V extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
function SearchableSelect<V extends string>({
|
||||
function CustomSelect<V extends string>({
|
||||
name,
|
||||
label,
|
||||
bottomText,
|
||||
@@ -130,39 +138,68 @@ function SearchableSelect<V extends string>({
|
||||
label?: string;
|
||||
bottomText?: string;
|
||||
error?: string;
|
||||
items: Array<{ value: V; resolvedLabel: string }>;
|
||||
items: Array<{
|
||||
value: V;
|
||||
resolvedLabel: string;
|
||||
description?: React.ReactNode;
|
||||
}>;
|
||||
value: V | null;
|
||||
onChange: (value: V | null) => void;
|
||||
onBlur?: () => void;
|
||||
clearable?: boolean;
|
||||
disabled?: boolean;
|
||||
searchPlaceholder: string;
|
||||
searchPlaceholder?: string;
|
||||
}) {
|
||||
const { translatedLabel } = useTranslatedTexts({ label });
|
||||
|
||||
const selectItems = items.map((item) => ({
|
||||
id: item.value,
|
||||
textValue: item.resolvedLabel,
|
||||
}));
|
||||
const hasDescriptions = items.some((item) => item.description);
|
||||
|
||||
// the Autocomplete wrapper of searchable selects drops falsy keys, so only
|
||||
// plain selects render the clear choice as a list item like the native
|
||||
// select's "—" option; searchable ones keep the clear button
|
||||
const hasEmptyItem = Boolean(clearable && !searchPlaceholder);
|
||||
|
||||
const selectItems = [
|
||||
...(hasEmptyItem
|
||||
? [{ id: "", textValue: "—", description: undefined }]
|
||||
: []),
|
||||
...items.map((item) => ({
|
||||
id: item.value as string,
|
||||
textValue: item.resolvedLabel,
|
||||
description: item.description,
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.searchable}>
|
||||
<SendouSelect
|
||||
label={translatedLabel}
|
||||
selectedKey={value}
|
||||
selectedKey={value ?? (hasEmptyItem ? "" : null)}
|
||||
onSelectionChange={(key) => {
|
||||
const newValue = key === "" ? null : (key as V);
|
||||
onChange(newValue);
|
||||
onBlur?.();
|
||||
}}
|
||||
items={selectItems}
|
||||
search={{ placeholder: searchPlaceholder }}
|
||||
clearable={clearable}
|
||||
search={
|
||||
searchPlaceholder ? { placeholder: searchPlaceholder } : undefined
|
||||
}
|
||||
clearable={clearable && !hasEmptyItem}
|
||||
isDisabled={disabled}
|
||||
estimatedRowHeight={hasDescriptions ? TWO_LINE_ROW_HEIGHT : undefined}
|
||||
>
|
||||
{(item) => (
|
||||
<SendouSelectItem id={item.id} textValue={item.textValue}>
|
||||
{item.textValue}
|
||||
{item.description ? (
|
||||
<span className={styles.twoLineItem}>
|
||||
<Text slot="label">{item.textValue}</Text>
|
||||
<Text slot="description" className={styles.itemDescription}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</span>
|
||||
) : (
|
||||
item.textValue
|
||||
)}
|
||||
</SendouSelectItem>
|
||||
)}
|
||||
</SendouSelect>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type * as React from "react";
|
||||
import type * as v from "valibot";
|
||||
import type { TeamSearchResult } from "~/components/elements/TeamSearch";
|
||||
import type { TournamentSearchItem } from "~/components/elements/TournamentSearch";
|
||||
@@ -59,6 +60,8 @@ interface FormFieldInGameName<T extends string> extends FormFieldBase<T> {
|
||||
interface FormFieldItem<V extends string> {
|
||||
label: string | number | ((lang: string) => string);
|
||||
value: V;
|
||||
/** Second line rendered under the label in the dropdown. Any item having one switches the field to the custom select. */
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface FormFieldItemWithImage<V extends string> extends FormFieldItem<V> {
|
||||
@@ -251,6 +254,8 @@ export type TrophyOption = {
|
||||
export type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
/** Second line rendered under the label in the dropdown. Any option having one switches the field to the custom select. */
|
||||
description?: React.ReactNode;
|
||||
};
|
||||
|
||||
/** Brand type to encode required options directly in schema types */
|
||||
|
||||
@@ -47,6 +47,15 @@ export class TournamentRegisterPage {
|
||||
return this.page.getByTestId(`member-num-${number}`);
|
||||
}
|
||||
|
||||
availabilityRow(userId: number) {
|
||||
return this.page.getByTestId(`availability-row-${userId}`);
|
||||
}
|
||||
|
||||
/** Opens the quick add dropdown so its player rows render. */
|
||||
async openQuickAdd() {
|
||||
await this.page.getByTestId("quick-add-select").getByRole("button").click();
|
||||
}
|
||||
|
||||
stepCheckmark(number: number) {
|
||||
return this.page.getByTestId(`checkmark-icon-num-${number}`);
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ test.describe("Tournament", () => {
|
||||
page,
|
||||
factories,
|
||||
}) => {
|
||||
const [partialMember, unknownMember, stranger, friend] =
|
||||
await factories.UserFactory.createMany(4);
|
||||
const [captain, partialMember, unknownMember, stranger, friend] =
|
||||
await factories.UserFactory.createMany(5);
|
||||
await factories.TeamFactory.create({
|
||||
memberUserIds: [ADMIN_ID, partialMember.id, unknownMember.id],
|
||||
memberUserIds: [captain.id, partialMember.id, unknownMember.id],
|
||||
});
|
||||
await factories.FriendshipFactory.create({
|
||||
userOneId: ADMIN_ID,
|
||||
userOneId: captain.id,
|
||||
userTwoId: friend.id,
|
||||
});
|
||||
|
||||
@@ -99,7 +99,7 @@ test.describe("Tournament", () => {
|
||||
await factories.TournamentTeamFactory.create({
|
||||
tournamentId: tournament.id,
|
||||
memberUserIds: [
|
||||
ADMIN_ID,
|
||||
captain.id,
|
||||
partialMember.id,
|
||||
unknownMember.id,
|
||||
stranger.id,
|
||||
@@ -114,7 +114,7 @@ test.describe("Tournament", () => {
|
||||
startsAt: dateToDatabaseTimestamp(startsAt),
|
||||
endsAt: dateToDatabaseTimestamp(addHours(startsAt, 5)),
|
||||
};
|
||||
for (const userId of [ADMIN_ID, friend.id]) {
|
||||
for (const userId of [captain.id, friend.id]) {
|
||||
await factories.AvailabilityWeekFactory.create({
|
||||
userId,
|
||||
weekStartsAt,
|
||||
@@ -134,14 +134,13 @@ test.describe("Tournament", () => {
|
||||
],
|
||||
});
|
||||
|
||||
await impersonate(page);
|
||||
await impersonate(page, captain.id);
|
||||
await setTimezoneCookie(page);
|
||||
const register = new TournamentRegisterPage(page);
|
||||
await register.goto(tournament.id);
|
||||
|
||||
const row = (userId: number) =>
|
||||
page.getByTestId(`availability-row-${userId}`);
|
||||
await expect(row(ADMIN_ID)).toHaveAttribute("data-status", "available");
|
||||
const row = (userId: number) => register.availabilityRow(userId);
|
||||
await expect(row(captain.id)).toHaveAttribute("data-status", "available");
|
||||
await expect(row(partialMember.id)).toHaveAttribute(
|
||||
"data-status",
|
||||
"partial",
|
||||
@@ -153,7 +152,8 @@ test.describe("Tournament", () => {
|
||||
// on the tournament roster without being a teammate or a friend, so
|
||||
// their schedule is not the viewer's to see
|
||||
await expect(row(stranger.id)).toHaveAttribute("data-status", "hidden");
|
||||
// the friend with an overlapping submitted range lands in the sub row
|
||||
// the friend with an overlapping submitted range is offered in quick add
|
||||
await register.openQuickAdd();
|
||||
await expect(row(friend.id)).toHaveAttribute("data-status", "available");
|
||||
});
|
||||
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privat",
|
||||
"or": "Eller",
|
||||
"inviteLink": "",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Udfyld holdmedlemslisten",
|
||||
"pre.roster.footer": "Mindst {{atLeastCount}} holdmedlemmer kræves for at deltage. Der kan maks være {{maxCount}} på holdet",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Fjern medlem",
|
||||
"pre.roster.delete.header": "Medlem der fjernes",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Vælg banepulje",
|
||||
"pre.pool.banned": "Bandlyst",
|
||||
"pre.pool.tiebreaker.short": "Tiebreaker",
|
||||
@@ -153,7 +154,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "tilføj Suppleant",
|
||||
"actions.shareLink": "Del invitationslinket for at tilføje medlemmer: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Du kan stadigvæk tilføje {{count}} Suppleant til din holdliste",
|
||||
"actions.sub.prompt_other": "Du kan stadigvæk tilføje {{count}} Suppleanter til din holdliste",
|
||||
"actions.sub.prompt_zero": "Din holdliste er fuld, så du kan ikke tilføje flere Suppleanter",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "",
|
||||
"or": "",
|
||||
"inviteLink": "",
|
||||
"yes": "",
|
||||
"no": "",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Roster füllen",
|
||||
"pre.roster.footer": "Mindestens {{atLeastCount}} Teammitglieder sind zum Spielen erforderlich. Maximale Rostergröße ist {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Mitglied löschen",
|
||||
"pre.roster.delete.header": "Zu entfernendes Mitglied",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Arenenpool wählen",
|
||||
"pre.pool.banned": "Gebannt",
|
||||
"pre.pool.tiebreaker.short": "Tiebreaker",
|
||||
@@ -153,7 +154,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Ersatzspieler hinzufügen",
|
||||
"actions.shareLink": "Teile deinen Invite-Link, um Mitglieder hinzuzufügen: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
|
||||
"actions.sub.prompt_other": "Du kannst noch {{count}} Ersatzspieler zu deinem Roster hinzufügen",
|
||||
"actions.sub.prompt_zero": "Dein Roster ist voll und keine weiteren Ersatzspieler können hinzugefügt werden",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "Without a link to the player page the request can not be considered. Screenshots are not necessary unless asked for.",
|
||||
"build.private": "Private",
|
||||
"or": "Or",
|
||||
"inviteLink": "Invite link",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"leaderboard.tabs.players": "Players",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Fill roster",
|
||||
"pre.roster.footer": "At least {{atLeastCount}} members are required to participate. Max roster size is {{maxCount}}.",
|
||||
"pre.roster.footer.noSubs": "Format is {{format}}. No subs allowed.",
|
||||
"pre.roster.addFriend.header": "Add friends",
|
||||
"pre.roster.addFriend.others": "Others",
|
||||
"pre.roster.delete.button": "Delete member",
|
||||
"pre.roster.delete.header": "Member to delete",
|
||||
"pre.roster.ignWarning": "Note that you are expected to use the in-game names as listed above. Playing in the event with a different name or using the alias feature might result in disqualification.",
|
||||
"pre.roster.quickAdd": "Quick add",
|
||||
"pre.roster.addMembers": "Add members",
|
||||
"pre.roster.emptySlot": "Empty slot",
|
||||
"pre.roster.emptySlot.optional": "Optional slot",
|
||||
"pre.roster.remove.confirm": "Remove {{name}} from the roster?",
|
||||
"pre.pool.header": "Pick map pool",
|
||||
"pre.pool.banned": "Banned",
|
||||
"pre.pool.tiebreaker.short": "Tiebreaker",
|
||||
@@ -153,7 +154,6 @@
|
||||
"staff.divider.addedForEvent": "For this event",
|
||||
"staff.editOrganization": "Edit organization",
|
||||
"actions.addSub": "Add sub",
|
||||
"actions.shareLink": "Share your invite link to add members: {{inviteLink}}",
|
||||
"actions.sub.prompt_other": "You can still add {{count}} subs to your roster",
|
||||
"actions.sub.prompt_one": "You can still add {{count}} sub to your roster",
|
||||
"actions.sub.prompt_zero": "Your roster is full and more subs can't be added",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
|
||||
"build.private": "Privado",
|
||||
"or": "O",
|
||||
"inviteLink": "",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Llenar equipo",
|
||||
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
|
||||
"pre.roster.addFriend.header": "Añadir amigos",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Borrar miembro",
|
||||
"pre.roster.delete.header": "Miembro que quieres borrar",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Escoger grupo de mapas",
|
||||
"pre.pool.banned": "Prohibidos",
|
||||
"pre.pool.tiebreaker.short": "Desempate",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "Para este evento",
|
||||
"staff.editOrganization": "Editar organización",
|
||||
"actions.addSub": "Añadir sub",
|
||||
"actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "No se puede tener en cuenta la solicitud sin un enlace a la página de jugador. No hace falta adjuntar capturas a menos que se pidan.",
|
||||
"build.private": "Privado",
|
||||
"or": "O",
|
||||
"inviteLink": "",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Llenar equipo",
|
||||
"pre.roster.footer": "Se requieren al menos {{atLeastCount}} miembros para participar. La cantidad máxima es {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "El formato es {{format}}. No se permiten subs.",
|
||||
"pre.roster.addFriend.header": "Añadir amigos",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Borrar miembro",
|
||||
"pre.roster.delete.header": "Miembro que quieres borrar",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Escoger grupo de escenarios",
|
||||
"pre.pool.banned": "Prohibidos",
|
||||
"pre.pool.tiebreaker.short": "Desempate",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "Para este evento",
|
||||
"staff.editOrganization": "Editar organización",
|
||||
"actions.addSub": "Añadir sub",
|
||||
"actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privé",
|
||||
"or": "Ou",
|
||||
"inviteLink": "",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Remplir la liste",
|
||||
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Effacer membre",
|
||||
"pre.roster.delete.header": "Membre à effacer",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Sélection de stage",
|
||||
"pre.pool.banned": "",
|
||||
"pre.pool.tiebreaker.short": "",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Ajouter remplaçant",
|
||||
"actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privé",
|
||||
"or": "Ou",
|
||||
"inviteLink": "",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Remplir la liste",
|
||||
"pre.roster.footer": "Au moins {{atLeastCount}} membres sont requis pour participer. La taille maximum est de {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Effacer membre",
|
||||
"pre.roster.delete.header": "Membre à effacer",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Sélection de stage",
|
||||
"pre.pool.banned": "Bannis",
|
||||
"pre.pool.tiebreaker.short": "Manche décisive",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Ajouter remplaçant",
|
||||
"actions.shareLink": "Partagez votre lien d'invitation pour ajouter des membres: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Vous pouvez encore ajouter {{count}} remplaçant à votre liste",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Vous pouvez encore ajouter {{count}} remplaçants à votre liste",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "פרטי",
|
||||
"or": "או",
|
||||
"inviteLink": "",
|
||||
"yes": "כן",
|
||||
"no": "לא",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "מלא צוות",
|
||||
"pre.roster.footer": "לפחות {{atLeastCount}} חברי צוות נדרשים כדי להשתתף. גודל הצוות המרבי הוא {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "מחקו חבר צוות",
|
||||
"pre.roster.delete.header": "חבר צוות למחיקה",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "בחרו מאגר מפות",
|
||||
"pre.pool.banned": "",
|
||||
"pre.pool.tiebreaker.short": "",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "הוסיפו ממלא מקום",
|
||||
"actions.shareLink": "שתפו קישור הזמנה להוספת חברי צוות: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "אתם עדיין יכולים להוסיף {{count}} ממלא מקום לצוות שלכם",
|
||||
"actions.sub.prompt_two": "",
|
||||
"actions.sub.prompt_other": "אתם עדיין יכולים להוסיף {{count}} ממלאי מקום לצוות שלכם",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privato",
|
||||
"or": "O",
|
||||
"inviteLink": "",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Riempi roster",
|
||||
"pre.roster.footer": "Sono necessari almeno {{atLeastCount}} membri per partecipare. La dimensione massima del roster è {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Elimina membro",
|
||||
"pre.roster.delete.header": "Membro da eliminare",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Scegli pool mappe",
|
||||
"pre.pool.banned": "Banneta",
|
||||
"pre.pool.tiebreaker.short": "Spareggio",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Aggiungi sub",
|
||||
"actions.shareLink": "Condividi il tuo link d'invito per aggiungere membri: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Puoi ancora aggiungere {{count}} sub al tuo roster",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Puoi ancora aggiungere {{count}} sub al tuo roster",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "非公開",
|
||||
"or": "または",
|
||||
"inviteLink": "",
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "参加プレイヤーを登録",
|
||||
"pre.roster.footer": "少なくとも {{atLeastCount}} 人の参加が必要です。最大メンバー数は {{maxCount}} です。",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "メンバーを削除する",
|
||||
"pre.roster.delete.header": "削除するメンバー",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "マッププールを選択する",
|
||||
"pre.pool.banned": "禁止",
|
||||
"pre.pool.tiebreaker.short": "タイブレイカー",
|
||||
@@ -151,7 +152,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "サブを追加",
|
||||
"actions.shareLink": "メンバー招待リンクをシェアする: {{inviteLink}}",
|
||||
"actions.sub.prompt_zero": "メンバーが上限に達しているので、これ以上サブを追加することができません",
|
||||
"actions.finalize": "",
|
||||
"actions.finalize.button": "",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Private",
|
||||
"or": "또는",
|
||||
"inviteLink": "",
|
||||
"yes": "네",
|
||||
"no": "아니오",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "",
|
||||
"pre.roster.footer": "",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "",
|
||||
"pre.roster.delete.header": "",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "",
|
||||
"pre.pool.banned": "",
|
||||
"pre.pool.tiebreaker.short": "",
|
||||
@@ -151,7 +152,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "",
|
||||
"actions.shareLink": "",
|
||||
"actions.sub.prompt_zero": "",
|
||||
"actions.finalize": "",
|
||||
"actions.finalize.button": "",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "",
|
||||
"or": "",
|
||||
"inviteLink": "",
|
||||
"yes": "",
|
||||
"no": "",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "",
|
||||
"pre.roster.footer": "",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "",
|
||||
"pre.roster.delete.header": "",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "",
|
||||
"pre.pool.banned": "",
|
||||
"pre.pool.tiebreaker.short": "",
|
||||
@@ -153,7 +154,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "",
|
||||
"actions.shareLink": "",
|
||||
"actions.sub.prompt_one": "",
|
||||
"actions.sub.prompt_other": "",
|
||||
"actions.sub.prompt_zero": "",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "",
|
||||
"or": "",
|
||||
"inviteLink": "",
|
||||
"yes": "",
|
||||
"no": "",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "",
|
||||
"pre.roster.footer": "",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "",
|
||||
"pre.roster.delete.header": "",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "",
|
||||
"pre.pool.banned": "",
|
||||
"pre.pool.tiebreaker.short": "",
|
||||
@@ -155,7 +156,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "",
|
||||
"actions.shareLink": "",
|
||||
"actions.sub.prompt_one": "",
|
||||
"actions.sub.prompt_few": "",
|
||||
"actions.sub.prompt_many": "",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Privada",
|
||||
"or": "Ou",
|
||||
"inviteLink": "",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Preencher lista",
|
||||
"pre.roster.footer": "Pelo menos {{atLeastCount}} membros são necessários para participar. O número máximo da lista de participantes é de {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Excluir membro",
|
||||
"pre.roster.delete.header": "Membro a ser excluído",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Escolher seleção de mapas",
|
||||
"pre.pool.banned": "Banido",
|
||||
"pre.pool.tiebreaker.short": "Desempate",
|
||||
@@ -154,7 +155,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Adicionar substituto(a)",
|
||||
"actions.shareLink": "Compartilhe seu link de convite para adicionar membros: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Você ainda pode adicionar {{count}} substituto(a) à sua lista",
|
||||
"actions.sub.prompt_many": "",
|
||||
"actions.sub.prompt_other": "Você ainda pode adicionar {{count}} substitutos(as) à sua lista",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "Приватный",
|
||||
"or": "Или",
|
||||
"inviteLink": "",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "Заполните состав",
|
||||
"pre.roster.footer": "Необходимый минимум игроков для данного турнира: {{atLeastCount}}. Максимальное количество игроков в составе: {{maxCount}}",
|
||||
"pre.roster.footer.noSubs": "",
|
||||
"pre.roster.addFriend.header": "",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "Удалить участника",
|
||||
"pre.roster.delete.header": "Участник для удаления",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "Выберите пул арен",
|
||||
"pre.pool.banned": "Запрещено",
|
||||
"pre.pool.tiebreaker.short": "Тайбрейк",
|
||||
@@ -155,7 +156,6 @@
|
||||
"staff.divider.addedForEvent": "",
|
||||
"staff.editOrganization": "",
|
||||
"actions.addSub": "Добавить запасного",
|
||||
"actions.shareLink": "Ссылка приглашения в команду: {{inviteLink}}",
|
||||
"actions.sub.prompt_one": "Вы ещё можете добавить {{count}} запасного",
|
||||
"actions.sub.prompt_few": "",
|
||||
"actions.sub.prompt_many": "",
|
||||
|
||||
@@ -360,6 +360,7 @@
|
||||
"xsearch.link.noScreenshots": "",
|
||||
"build.private": "私人",
|
||||
"or": "或",
|
||||
"inviteLink": "",
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
"leaderboard.tabs.players": "",
|
||||
|
||||
@@ -57,11 +57,12 @@
|
||||
"pre.roster.header": "填写阵容",
|
||||
"pre.roster.footer": "至少需要 {{atLeastCount}} 名成员才能参赛。最大阵容人数为 {{maxCount}} 人。",
|
||||
"pre.roster.footer.noSubs": "赛制为 {{format}}。不允许替补。",
|
||||
"pre.roster.addFriend.header": "添加好友",
|
||||
"pre.roster.addFriend.others": "",
|
||||
"pre.roster.delete.button": "删除成员",
|
||||
"pre.roster.delete.header": "要删除的成员",
|
||||
"pre.roster.ignWarning": "",
|
||||
"pre.roster.quickAdd": "",
|
||||
"pre.roster.addMembers": "",
|
||||
"pre.roster.emptySlot": "",
|
||||
"pre.roster.emptySlot.optional": "",
|
||||
"pre.roster.remove.confirm": "",
|
||||
"pre.pool.header": "选择场地池",
|
||||
"pre.pool.banned": "已禁用",
|
||||
"pre.pool.tiebreaker.short": "决胜局场地",
|
||||
@@ -152,7 +153,6 @@
|
||||
"staff.divider.addedForEvent": "仅限此赛事",
|
||||
"staff.editOrganization": "编辑组织",
|
||||
"actions.addSub": "添加替补",
|
||||
"actions.shareLink": "分享您的邀请链接以添加成员: {{inviteLink}}",
|
||||
"actions.sub.prompt": "您仍可以向阵容中添加 {{count}} 名替补",
|
||||
"actions.sub.prompt_zero": "您的阵容已满,无法添加更多替补",
|
||||
"actions.finalize": "正在结束赛事",
|
||||
|
||||
Reference in New Issue
Block a user