diff --git a/app/components/InviteLinkInput.module.css b/app/components/InviteLinkInput.module.css new file mode 100644 index 000000000..cceca9c07 --- /dev/null +++ b/app/components/InviteLinkInput.module.css @@ -0,0 +1,10 @@ +.row { + display: flex; + align-items: center; + gap: var(--s-2); + + & input { + flex: 1; + min-width: 0; + } +} diff --git a/app/components/InviteLinkInput.tsx b/app/components/InviteLinkInput.tsx new file mode 100644 index 000000000..c9e1a5fb8 --- /dev/null +++ b/app/components/InviteLinkInput.tsx @@ -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 ( +
+ +
+ e.currentTarget.select()} + data-testid="invite-link-input" + /> + copyToClipboard(link)} + icon={copySuccess ? : } + aria-label={t("common:actions.copyToClipboard")} + data-testid="copy-invite-link-button" + /> +
+
+ ); +} diff --git a/app/components/elements/Select.module.css b/app/components/elements/Select.module.css index 45bc325d0..a5210a0cf 100644 --- a/app/components/elements/Select.module.css +++ b/app/components/elements/Select.module.css @@ -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 { diff --git a/app/components/elements/SelectShell.module.css b/app/components/elements/SelectShell.module.css index 104d1a5b6..05ca5989b 100644 --- a/app/components/elements/SelectShell.module.css +++ b/app/components/elements/SelectShell.module.css @@ -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); diff --git a/app/components/elements/SelectShell.tsx b/app/components/elements/SelectShell.tsx index 91a9a8b30..cb60fbdb4 100644 --- a/app/components/elements/SelectShell.tsx +++ b/app/components/elements/SelectShell.tsx @@ -116,7 +116,7 @@ export function SelectShellItem({ - clsx(className, { + clsx(className, styles.item, { [styles.itemFocused]: isFocused, [styles.itemSelected]: isSelected, }) diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.module.css b/app/features/availability/components/RegistrationAvailabilityPanel.module.css index d3f1a9915..9df9945fe 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.module.css +++ b/app/features/availability/components/RegistrationAvailabilityPanel.module.css @@ -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; diff --git a/app/features/availability/components/RegistrationAvailabilityPanel.tsx b/app/features/availability/components/RegistrationAvailabilityPanel.tsx index 6eaa9cea6..4ac9b4d44 100644 --- a/app/features/availability/components/RegistrationAvailabilityPanel.tsx +++ b/app/features/availability/components/RegistrationAvailabilityPanel.tsx @@ -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; -type PanelEntry = NonNullable[number]; +export type AvailabilityPanelData = SerializeFrom; +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 = [ + "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; + availability: AvailabilityPanelData; + roster: Array; /** Friends not on the shown roster and not in the tournament, panel keeps the free ones. */ - subCandidates: Array; + subCandidates: Array; }) { 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 (

{t("schedule:registration.title")} ·{" "} - - {windowFormatter.formatRange( - availability.window.startsAt, - availability.window.endsAt, - )}{" "} - ({t("schedule:registration.estimated")}) - +

-
    - {roster.map((user) => ( - - ))} -
- - {freeSubs.length > 0 ? ( -
-
- {t("schedule:registration.friends")} -
+ {roster.length > 0 ? ( + <>
    - {freeSubs.map((user) => ( - ( + ))}
-
+ + availabilityRowStatus(entryByUserId.get(user.id)), + )} + /> + + ) : null} + {freeSubs.length > 0 ? ( + roster.length > 0 ? ( +
+
+ {t("schedule:registration.friends")} +
+
    + {freeSubs.map((user) => ( + + ))} +
+
+ ) : ( +
    + {freeSubs.map((user) => ( + + ))} +
+ ) ) : null}
); } -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 (
  • - + {showAvailability ? : null} - {user.username} - - {entry?.notes.map((note) => ( - - {note} - - ))} + + {primaryName ?? user.username} + {secondaryName ? ( + {secondaryName} + ) : null} + + {showAvailability ? : null} + {showAvailability + ? entry?.notes.map((note) => ( + + {note} + + )) + : null} + {trailing ? {trailing} : null}
  • ); } -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 ( - + {t("schedule:registration.notVisible")} ); @@ -168,13 +233,13 @@ function RowDetail({ entry }: { entry?: PanelEntry }) { return ; case "unavailable": return ( - + {t("schedule:team.notAvailable")} ); case "unknown": return ( - + {t("schedule:team.noSchedule")} ); @@ -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; +}) { + const { t } = useTranslation(["schedule"]); + const { formatter } = useDateTimeFormat({ + weekday: "short", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); + + return ( + + {formatter.formatRange(window.startsAt, window.endsAt)} ( + {t("schedule:registration.estimated")}) + + ); +} + +/** Counts by status, e.g. "2 available · 1 partial · 1 out". */ +export function AvailabilitySummary({ + statuses, + className, +}: { + statuses: Array; + 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 ( + {parts.join(" · ")} + ); +} + +/** 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; +}) { + 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 ( + + {shown.map((status, i) => ( + + ))} + + ); +} + function RangesText({ ranges }: { ranges: Array }) { const { formatter: timeFormatter } = useDateTimeFormat({ hour: "numeric", @@ -209,47 +347,39 @@ function RangesText({ ranges }: { ranges: Array }) { ); } -function StatusIcon({ status }: { status: RowStatus }) { +function StatusIcon({ status }: { status: AvailabilityRowStatus }) { + return ( + + {statusGlyph(status)} + + ); +} + +function statusGlyph(status: AvailabilityRowStatus) { switch (status) { case "available": - return ; + return ( + + ); case "partial": - return ; + return ; case "unavailable": - return ; + return ; case "busy": - return ; + return ( + + ); case "unknown": - return ; + return ( + + ); case "hidden": - return ; + return ( + + ); } } - -function SummaryLine({ - roster, - entryByUserId, -}: { - roster: Array; - entryByUserId: Map; -}) { - 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
    {parts.join(" · ")}
    ; -} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index fbbdd42d2..bf7b3e096 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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(); @@ -465,19 +463,7 @@ function AddSubsPopOver() { {subsAvailableToAdd > 0 ? ( <> -
    {t("tournament:actions.shareLink", { inviteLink })}
    -
    - : } - onPress={() => copyToClipboard(inviteLink)} - variant="minimal" - className="tiny" - data-testid="copy-invite-link-button" - > - {t("common:actions.copyToClipboard")} - -
    + ) : null} diff --git a/app/features/tournament/routes/to.$id.register.module.css b/app/features/tournament/routes/to.$id.register.module.css index 04c9aef9d..34b0f79a9 100644 --- a/app/features/tournament/routes/to.$id.register.module.css +++ b/app/features/tournament/routes/to.$id.register.module.css @@ -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); } } diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 124e3ec9e..62670ea9f 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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 = { + 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 ? : null} {tournament.isLeague && tournament.ctx.organization?.id === LUTI_ORGANIZATION_ID ? ( @@ -259,7 +275,6 @@ function ReadOnlyRegistrationForms() { members={team.members} /> - {tournament.teamsPrePickMaps ? ( @@ -544,7 +559,7 @@ function TeamInfo({ -
    - -
    -
    - -
    + + ); @@ -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 ? ( + + + + + ) : undefined, + }; + }); const showTeamSelect = teamOptions.length > 0 && tournament.registrationOpen; return ( <> {showTeamSelect ? ( -
    - -
    + ) : null} {!data?.ownTeam ? : null} {!isLinked ? ( <> -
    - -
    -
    - -
    + + ) : null} @@ -667,8 +689,11 @@ function FillRoster({ }) { const data = useLoaderData(); 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 (
    -

    - 2. {t("tournament:pre.roster.header")} -

    -
    - {playersAvailableToDirectlyAdd.length > 0 && canAddMembers ? ( - <> - - {t("common:or")} - +
    +

    + 2. {t("tournament:pre.roster.header")} +

    + {availability?.window ? ( + ) : null} - {canAddMembers ? ( -
    -
    - {t("tournament:actions.shareLink", { inviteLink })} -
    -
    - : } - onPress={() => copyToClipboard(inviteLink)} - variant="outlined" - > - {t("common:actions.copyToClipboard")} - -
    +
    +
    + {availability?.beyondHorizon ? ( +
    + {t("schedule:registration.beyondHorizon", { + date: dateFormatter.format(availability.beyondHorizon.opensAt), + })}
    ) : null} -
    - {ownTeamMembers.map((member, i) => { - return ( -
    - - {tournament.ctx.settings.requireInGameNames ? ( -
    -
    - {member.inGameName ?? member.username} -
    - {member.inGameName ? ( -
    - {member.username} -
    - ) : null} -
    - ) : ( -
    - {member.username} -
    - )} -
    - ); - })} - {new Array(missingMembers).fill(null).map((_, i) => { - return ( -
    - ? -
    - ); - })} - {new Array(optionalMembers).fill(null).map((_, i) => { - return ( -
    + {ownTeamMembers.map((member, i) => ( + + ) : null + } + /> + ))} + {Array.from({ length: missingMembers }).map((_, i) => ( +
  • + + + + {t("tournament:pre.roster.emptySlot")} +
  • + ))} + {Array.from({ length: optionalMembers }).map((_, i) => ( +
  • + - ? -
  • - ); - })} -
    - {showDeleteMemberSection ? ( - + + + {t("tournament:pre.roster.emptySlot.optional")} + + ))} + + {entryByUserId ? ( + + availabilityRowStatus(entryByUserId.get(member.userId)), + )} + /> + ) : null} + {canAddMembers ? ( +
    +

    + {t("tournament:pre.roster.addMembers")} +

    + {quickAddPlayers.length > 0 ? ( + player.id).join(",")} + players={quickAddPlayers} + entryByUserId={entryByUserId} + /> + ) : null} + +
    ) : null}
    {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 | null; }) { const { t } = useTranslation(["tournament", "common"]); const fetcher = useFetcher(); - const id = React.useId(); - const othersOptions = players - .filter((player) => !player.teamId) - .map((player) => { - return ( - - ); - }); + const sortedPlayers = entryByUserId + ? R.sortBy( + players, + (player) => + QUICK_ADD_STATUS_ORDER[ + availabilityRowStatus(entryByUserId.get(player.id)) + ], + ) + : players; + const [selectedUserId, setSelectedUserId] = React.useState( + sortedPlayers[0]?.id ?? null, + ); + + // xxx: split team, pickup. quick button to add all player roles for team return ( - -
    - - + + {selectedUserId ? ( + + ) : null} + + {t("common:actions.add")} +
    - - {t("common:actions.add")} -
    ); } -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 ( + setExpanded(true)} - > - {t("tournament:pre.roster.delete.button")} - - ); - } - - return ( - - -
    - - - {t("common:actions.delete")} - -
    -
    + icon={} + aria-label={t("common:actions.remove")} + testId={`remove-member-${member.userId}`} + /> +
    ); } @@ -976,51 +1028,26 @@ function TeamCounterPickMapPoolPicker({ ); } -function TournamentRosterAvailability({ - ownTeam, -}: { - ownTeam: TournamentTeamFull; -}) { +function SelectedTeamAvailability() { const data = useLoaderData(); 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 ( - user.id), - })} - /> - ); -} - -function SelectedTeamAvailability() { - const data = useLoaderData(); - 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 ( user.id), + rosterUserIds: teamId + ? roster.map((rosterUser) => rosterUser.id) + : user + ? [user.id] + : [], })} /> ); @@ -1051,6 +1082,29 @@ function panelUser(user: { }; } +function availabilityEntryByUserId( + data: ReturnType>, +) { + 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>; + teamId: number; + entryByUserId: Map; +}): Array { + return (data?.friendPlayers?.friends ?? []) + .filter((friend) => friend.teamId === teamId) + .map((friend) => availabilityRowStatus(entryByUserId.get(friend.id))); +} + function subCandidates({ data, tournament, diff --git a/app/form/FormField.tsx b/app/form/FormField.tsx index f72b7c1eb..437b96148 100644 --- a/app/form/FormField.tsx +++ b/app/form/FormField.tsx @@ -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} diff --git a/app/form/fields/SelectFormField.module.css b/app/form/fields/SelectFormField.module.css index 1cde6010b..12e39f777 100644 --- a/app/form/fields/SelectFormField.module.css +++ b/app/form/fields/SelectFormField.module.css @@ -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; +} diff --git a/app/form/fields/SelectFormField.tsx b/app/form/fields/SelectFormField.tsx index 035c2815a..1ce504c65 100644 --- a/app/form/fields/SelectFormField.tsx +++ b/app/form/fields/SelectFormField.tsx @@ -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 = Omit< FormFieldProps<"select">, "items" | "clearable" | "onBlur" | "name" | "searchable" @@ -56,12 +59,17 @@ export function SelectFormField({ return { value: item.value, resolvedLabel, + description: item.description, }; }); - if (searchable) { + const hasDescriptions = itemsWithResolvedLabels.some( + (item) => item.description, + ); + + if (searchable || hasDescriptions) { return ( - ({ 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({ ); } -function SearchableSelect({ +function CustomSelect({ name, label, bottomText, @@ -130,39 +138,68 @@ function SearchableSelect({ 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 (
    { 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) => ( - {item.textValue} + {item.description ? ( + + {item.textValue} + + {item.description} + + + ) : ( + item.textValue + )} )} diff --git a/app/form/types.ts b/app/form/types.ts index de732c9fa..e4e01eef8 100644 --- a/app/form/types.ts +++ b/app/form/types.ts @@ -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 extends FormFieldBase { interface FormFieldItem { 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 extends FormFieldItem { @@ -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 */ diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 1e85f06b6..a118bc9a9 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -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}`); } diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index 47cc73aae..591cbf111 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -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"); }); diff --git a/locales/da/common.json b/locales/da/common.json index 25445b424..f4413345f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privat", "or": "Eller", + "inviteLink": "", "yes": "Ja", "no": "Nej", "leaderboard.tabs.players": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 6065bf5fb..4cdebbe67 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index b88ca0d4e..804deb45b 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 72c36c752..0acb9ba0c 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index bd45a0ba0..909e6caec 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 0b225d3a0..bb2e74d9b 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 74eca0fac..0999aa835 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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": "", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index a658e54bc..e07c3b283 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -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", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 088361e5b..fc397756a 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index c21641bf5..27dee619e 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -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", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 88d6efcfb..54200edb0 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privé", "or": "Ou", + "inviteLink": "", "yes": "Oui", "no": "Non", "leaderboard.tabs.players": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 9de3b4e3c..eb2ee09dd 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -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", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index e59d41185..927a88c05 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privé", "or": "Ou", + "inviteLink": "", "yes": "Oui", "no": "Non", "leaderboard.tabs.players": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 8ba5e4c73..5ee8d9408 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 0c4460ded..37655eb30 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "פרטי", "or": "או", + "inviteLink": "", "yes": "כן", "no": "לא", "leaderboard.tabs.players": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 3555e5576..7d2b59b4b 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -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}} ממלאי מקום לצוות שלכם", diff --git a/locales/it/common.json b/locales/it/common.json index 379706b0b..5c018c72c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privato", "or": "O", + "inviteLink": "", "yes": "Sì", "no": "No", "leaderboard.tabs.players": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 3c6e54276..56e96581f 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index f02a9d7b3..242eab479 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "非公開", "or": "または", + "inviteLink": "", "yes": "はい", "no": "いいえ", "leaderboard.tabs.players": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 030b0d28b..313eb7df3 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -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": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 22e38c156..a0b27a283 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Private", "or": "또는", + "inviteLink": "", "yes": "네", "no": "아니오", "leaderboard.tabs.players": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index bef3bf695..bfb94770a 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -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": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 1df29613d..b548ac021 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 0e768460f..c80ec0b42 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -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": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 74f7b186b..2ad53ffbe 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "", "or": "", + "inviteLink": "", "yes": "", "no": "", "leaderboard.tabs.players": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 45d245417..dcf9c2abb 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -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": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 6a21b22e4..d75a4dd7f 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Privada", "or": "Ou", + "inviteLink": "", "yes": "Sim", "no": "Não", "leaderboard.tabs.players": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index c44589c13..637ea2159 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index aa691577d..e22052721 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "Приватный", "or": "Или", + "inviteLink": "", "yes": "Да", "no": "Нет", "leaderboard.tabs.players": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 4e7e5bff4..868ee9d2d 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -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": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5aef5d4a8..255324406 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -360,6 +360,7 @@ "xsearch.link.noScreenshots": "", "build.private": "私人", "or": "或", + "inviteLink": "", "yes": "是", "no": "否", "leaderboard.tabs.players": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index b9b53fff4..3dc548979 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -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": "正在结束赛事",