Match page get rid of join tab

This commit is contained in:
Kalle
2026-06-17 17:32:54 +03:00
parent b19e942709
commit c7ba494b2b
102 changed files with 173 additions and 1038 deletions

View File

@@ -2,12 +2,8 @@ import clsx from "clsx";
import { ArrowLeft, MessageSquare, X } from "lucide-react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Link } from "react-router";
import { useCurrentRouteChatCode } from "~/features/chat/ChatProvider";
import {
extractRoomLink,
isMatchRoomUrl,
} from "~/features/chat/chat-constants";
import { resolveDatePlaceholders } from "~/features/chat/chat-utils";
import { Chat } from "~/features/chat/components/Chat";
import { useChatContext } from "~/features/chat/useChatContext";
@@ -178,7 +174,6 @@ function ChatView({ onClose }: { onClose?: () => void }) {
.filter(([code]) => code !== activeRoom)
.reduce((sum, [, count]) => sum + count, 0);
const roomLinkFetcher = useFetcher();
const room = chatContext.rooms.find((r) => r.chatCode === activeRoom);
const roomExpired = Boolean(room?.expiresAt && room.expiresAt < Date.now());
const messages = chatContext.messagesForRoom(activeRoom);
@@ -194,26 +189,10 @@ function ChatView({ onClose }: { onClose?: () => void }) {
}
}
const isMatchRoom = room?.url ? isMatchRoomUrl(room.url) : false;
const chatAdapter = {
messages,
send: (contents: string) => {
chatContext.send(activeRoom, contents);
if (isMatchRoom) {
const link = extractRoomLink(contents);
if (link) {
roomLinkFetcher.submit(
{ _action: "UPSERT", url: link },
{
method: "post",
action: "/room",
encType: "application/json",
},
);
}
}
},
currentRoom: activeRoom,
setCurrentRoom: () => {},

View File

@@ -18,12 +18,15 @@
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);
padding: var(--s-2);
padding: var(--s-2-5);
background-image:
linear-gradient(
to top,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6)
),
var(--stage-img);
@@ -50,32 +53,39 @@
bottom: var(--s-2);
right: var(--s-2);
display: flex;
gap: var(--s-0-5);
gap: var(--s-1);
align-items: center;
color: var(--color-text-high);
background-color: var(--color-bg-high);
padding: var(--s-0-5) var(--s-1-5);
border-radius: var(--radius-field);
font-size: var(--font-3xs);
font-weight: normal;
color: inherit;
font-size: inherit;
font-weight: inherit;
height: auto;
}
.joinBadge {
.joinInfo {
position: absolute;
bottom: var(--s-2);
left: var(--s-2);
display: flex;
gap: var(--s-1);
align-items: center;
color: var(--color-text-high);
background-color: var(--color-bg-high);
padding: var(--s-0-5) var(--s-1-5);
border-radius: var(--radius-field);
font-size: var(--font-2xs);
gap: var(--s-4);
}
.joinInfoItem {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.joinInfoLabel {
text-transform: uppercase;
font-size: var(--font-xs);
font-weight: var(--weight-semi);
height: 32px;
width: 32px;
opacity: 0.7;
}
.joinInfoValue {
font-size: var(--font-lg);
font-weight: var(--weight-bold);
letter-spacing: 1px;
}
.infoBadge {
@@ -120,8 +130,11 @@
background-image:
linear-gradient(
to top,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.6),
rgba(0, 0, 0, 0.6)
),
var(--stage-img);
@@ -149,7 +162,8 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-1);
gap: var(--s-0-5);
line-height: 1.2;
width: 100%;
height: var(--banner-height);
border-radius: var(--radius-box);

View File

@@ -1,7 +1,6 @@
import clsx from "clsx";
import { Check, QrCode, X } from "lucide-react";
import { Check, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
@@ -22,7 +21,7 @@ interface MatchBannerProps {
mode: ModeShort;
screenLegal?: boolean;
joinPool?: string | null;
joinViaQr?: boolean;
joinPass?: string | null;
children?: React.ReactNode;
}
@@ -31,7 +30,7 @@ export function MatchBanner({
mode,
screenLegal,
joinPool,
joinViaQr,
joinPass,
children,
}: MatchBannerProps) {
const { t } = useTranslation(["game-misc"]);
@@ -50,7 +49,7 @@ export function MatchBanner({
</div>
<div className={clsx(styles.info, styles.thickText)}>{children}</div>
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
{joinPool ? <JoinInfo pool={joinPool} pass={joinPass} /> : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
@@ -82,7 +81,7 @@ interface IconBannerProps {
subtitle?: string;
screenLegal?: boolean;
joinPool?: string | null;
joinViaQr?: boolean;
joinPass?: string | null;
topRight?: React.ReactNode;
testId?: string;
}
@@ -93,7 +92,7 @@ export function IconBanner({
subtitle,
screenLegal,
joinPool,
joinViaQr,
joinPass,
topRight,
testId,
}: IconBannerProps) {
@@ -104,7 +103,7 @@ export function IconBanner({
{subtitle ? (
<div className={styles.iconBannerSubtitle}>{subtitle}</div>
) : null}
{joinPool ? <JoinPoolBadge pool={joinPool} viaQr={joinViaQr} /> : null}
{joinPool ? <JoinInfo pool={joinPool} pass={joinPass} /> : null}
{screenLegal !== undefined ? (
<ScreenNotice screenLegal={screenLegal} />
) : null}
@@ -115,35 +114,33 @@ export function IconBanner({
);
}
function JoinPoolBadge({ pool, viaQr }: { pool: string; viaQr?: boolean }) {
function JoinInfo({ pool, pass }: { pool: string; pass?: string | null }) {
const { t } = useTranslation(["q"]);
const [, setSearchParams] = useSearchParams();
return (
<SendouButton
variant="minimal"
className={styles.joinBadge}
onPress={() =>
setSearchParams(
{ tab: "join" },
{
preventScrollReset: true,
defaultShouldRevalidate: false,
},
)
}
aria-label={t("q:match.pool")}
testId="join-pool-badge"
>
{viaQr ? <QrCode size={18} /> : pool}
</SendouButton>
<div className={styles.joinInfo}>
<div className={styles.joinInfoItem}>
<div className={styles.joinInfoLabel}>{t("q:match.pool")}</div>
<div className={styles.joinInfoValue}>{pool}</div>
</div>
{pass ? (
<div className={styles.joinInfoItem}>
<div className={styles.joinInfoLabel}>
{t("q:match.password.short")}
</div>
<div className={styles.joinInfoValue} data-testid="room-pass">
{pass}
</div>
</div>
) : null}
</div>
);
}
function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
const { t } = useTranslation(["weapons", "q"]);
const imgSize = 18;
const imgSize = 24;
const Icon = screenLegal ? Check : X;
@@ -156,16 +153,16 @@ function ScreenNotice({ screenLegal }: { screenLegal: boolean }) {
testId={screenLegal ? "screen-allowed" : "screen-banned"}
aria-label={screenLegal ? "Screen allowed" : "Screen banned"}
>
<Icon
size={imgSize}
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
/>
<img
src={`${specialWeaponImageUrl(19)}.avif`}
width={imgSize}
height={imgSize}
alt=""
/>
<Icon
size={imgSize}
className={screenLegal ? styles.legalIcon : styles.illegalIcon}
/>
</SendouButton>
}
>

View File

@@ -1,96 +0,0 @@
.joinContent {
display: grid;
grid-template-areas: "time x" "qr join";
grid-template-columns: auto minmax(0, max-content);
gap: var(--s-1) var(--s-4);
justify-content: center;
max-width: 100%;
}
.joinInfo {
display: flex;
flex-direction: column;
grid-area: join;
gap: var(--s-2);
min-width: 0;
}
.infoHeader {
text-transform: uppercase;
color: var(--color-text-high);
font-size: var(--font-2xs);
line-height: 1.1;
}
.infoValue {
font-size: var(--font-lg);
font-weight: var(--weight-semi);
letter-spacing: 1px;
}
.infoValueTruncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.qrCodeContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
grid-area: qr;
}
.roomAge {
grid-area: time;
font-size: var(--font-2xs);
color: var(--color-text-high);
text-align: center;
}
.qrCode {
background-color: white;
padding: var(--s-2);
border-radius: var(--radius-field);
}
.joinLink {
font-size: var(--font-2xs);
text-overflow: ellipsis;
overflow-x: hidden;
text-wrap: nowrap;
max-width: 140px;
}
.qrOverlay {
width: 172px;
height: 172px;
border-radius: var(--radius-field);
padding: var(--s-2);
background-color: var(--color-bg-higher);
grid-area: qr;
}
.stalePrompt {
display: flex;
gap: var(--s-6);
flex-direction: column;
align-items: center;
justify-content: center;
}
.staleText {
font-size: var(--font-sm);
color: var(--color-text-high);
text-align: center;
}
.noRoomHint {
font-size: var(--font-sm);
color: var(--color-text-high);
display: grid;
place-items: center;
text-align: center;
}

View File

@@ -1,158 +0,0 @@
import clsx from "clsx";
import { QRCodeSVG } from "qrcode.react";
import { useTranslation } from "react-i18next";
import { Alert } from "~/components/Alert";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import { SendouButton } from "../elements/Button";
import { SendouTabPanel } from "../elements/Tabs";
import styles from "./MatchJoinTab.module.css";
import { TAB_KEYS } from "./MatchTabs";
interface MatchJoinTabProps {
joinLink?: string;
hostedBy?: string;
pool: string;
pass: string;
showNoSplatnetAlert: boolean;
isStale?: boolean;
staleMinutesAgo?: number;
refreshedAt?: Date;
onConfirmRoom?: () => void;
isConfirming?: boolean;
}
export function MatchJoinTab({
joinLink,
hostedBy,
pool,
pass,
showNoSplatnetAlert,
isStale,
staleMinutesAgo,
refreshedAt,
onConfirmRoom,
isConfirming,
}: MatchJoinTabProps) {
const { t } = useTranslation(["q"]);
const formatDistanceToNow = useFormatDistanceToNow();
return (
<SendouTabPanel id={TAB_KEYS.JOIN}>
<div className="stack lg">
{showNoSplatnetAlert ? (
<Alert variation="WARNING" tiny>
{t("q:match.noSplatnetWarning")}
</Alert>
) : null}
<div className={styles.joinContent}>
{joinLink ? (
isStale ? (
<StaleRoomPrompt
minutesAgo={staleMinutesAgo ?? 0}
onConfirm={onConfirmRoom}
isConfirming={isConfirming}
/>
) : (
<>
{refreshedAt ? (
<div className={styles.roomAge}>
{formatDistanceToNow(refreshedAt, { addSuffix: true })}
</div>
) : null}
<div className={styles.qrCodeContainer}>
<QRCodeSVG
value={joinLink}
size={140}
className={styles.qrCode}
/>
<a
href={joinLink}
target="_blank"
rel="noopener noreferrer"
className={styles.joinLink}
>
{joinLink}
</a>
</div>
</>
)
) : (
<div className={clsx(styles.qrOverlay, styles.noRoomHint)}>
{t("q:match.room.noRoomHint")}
</div>
)}
<div className={styles.joinInfo}>
{hostedBy ? (
<InfoWithHeader
header={t("q:match.hostedBy")}
value={hostedBy}
truncate
/>
) : null}
<InfoWithHeader header={t("q:match.pool")} value={pool} />
<InfoWithHeader
header={t("q:match.password.short")}
value={pass}
testId="room-pass"
/>
</div>
</div>
</div>
</SendouTabPanel>
);
}
function StaleRoomPrompt({
minutesAgo,
onConfirm,
isConfirming,
}: {
minutesAgo: number;
onConfirm?: () => void;
isConfirming?: boolean;
}) {
const { t } = useTranslation(["q"]);
return (
<div className={clsx(styles.qrOverlay, styles.stalePrompt)}>
<div className={styles.staleText}>
{t("q:match.room.stalePrompt", { minutes: minutesAgo })}
</div>
<SendouButton
variant="outlined"
size="small"
onPress={onConfirm}
isDisabled={isConfirming}
>
{t("q:match.room.confirm")}
</SendouButton>
</div>
);
}
function InfoWithHeader({
header,
value,
testId,
truncate,
}: {
header: string;
value: string;
testId?: string;
truncate?: boolean;
}) {
return (
<div>
<div className={styles.infoHeader}>{header}</div>
<div
className={clsx(styles.infoValue, {
[styles.infoValueTruncate]: truncate,
})}
data-testid={testId}
title={truncate ? value : undefined}
>
{value}
</div>
</div>
);
}

View File

@@ -50,6 +50,8 @@ interface RosterTabTeam {
subbedOut?: Array<number>;
tier?: { name: TierName; isPlus: boolean };
seed?: number | null;
/** Whether this team is expected to host the room (tournament only). */
isHost?: boolean;
}
interface MatchRosterTabProps {
@@ -261,6 +263,8 @@ function TeamHeader({
label: string;
dotClassName: string;
}) {
const { t } = useTranslation(["common"]);
const tierText = team.tier
? `${team.tier.name.toLowerCase()}${team.tier.isPlus ? "+" : ""}`
: undefined;
@@ -282,6 +286,12 @@ function TeamHeader({
<span>{seedText}</span>
</>
) : null}
{team.isHost ? (
<>
<span></span>
<span>{t("common:host")}</span>
</>
) : null}
</div>
);

View File

@@ -1,15 +1,7 @@
import {
BarChart3,
DoorOpen,
Key,
ScrollText,
Tally5,
Users,
} from "lucide-react";
import { BarChart3, Key, ScrollText, Tally5, Users } from "lucide-react";
import type * as React from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router";
import { useUser } from "~/features/auth/core/user";
import invariant from "~/utils/invariant";
import { SendouTab, SendouTabList, SendouTabs } from "../elements/Tabs";
import styles from "./MatchTabs.module.css";
@@ -27,7 +19,6 @@ const TAB_KEY = "tab";
export const TAB_KEYS = {
ROSTERS: "rosters",
ACTION: "action",
JOIN: "join",
RESULT: "result",
STATS: "stats",
ADMIN: "admin",
@@ -36,7 +27,6 @@ export const TAB_KEYS = {
const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
rosters: <Users />,
action: <Tally5 />,
join: <DoorOpen />,
result: <ScrollText />,
stats: <BarChart3 />,
admin: <Key />,
@@ -45,7 +35,6 @@ const TAB_ICONS: Record<MatchTabsKey, React.ReactNode> = {
const TAB_TRANSLATION_KEYS = {
rosters: "q:match.tabs.rosters",
action: "q:match.tabs.action",
join: "common:actions.join",
result: "q:match.tabs.result",
stats: "q:match.tabs.stats",
admin: "common:pages.admin",
@@ -54,12 +43,9 @@ const TAB_TRANSLATION_KEYS = {
export function MatchTabs({ children, tabs, alertTabs }: MatchTabsProps) {
const { t } = useTranslation(["q", "common"]);
const [searchParams, setSearchParams] = useSearchParams();
const user = useUser();
const preferredTab = user?.preferences.defaultMatchPageTab;
const currentTab =
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ??
(preferredTab && tabs.includes(preferredTab) ? preferredTab : tabs.at(0));
tabs.find((tab) => searchParams.get(TAB_KEY) === tab) ?? tabs.at(0);
invariant(currentTab);
return (

View File

@@ -2726,23 +2726,6 @@ async function groups(variation?: SeedVariation | null) {
expiresAfter: { hours: 2 },
});
}
const thirtyMinutesAgo = dateToDatabaseTimestamp(
sub(new Date(), { minutes: 30 }),
);
sql
.prepare(
/* sql */ `
insert into "RoomLink" ("userId", "url", "createdAt", "refreshedAt")
values (@userId, @url, @createdAt, @refreshedAt)
`,
)
.run({
userId: ADMIN_ID,
url: "https://example.com//private_battle/seed_room_123",
createdAt: thirtyMinutesAgo,
refreshedAt: thirtyMinutesAgo,
});
}
}

View File

@@ -1070,8 +1070,6 @@ export interface UserPreferences {
/** Is spoiler-free mode enabled? Hides recent tournament results and scores until the user chooses to reveal them. */
spoilerFreeMode?: boolean;
weaponReportDefaultOpen?: boolean;
/** Which tab opens first on the match page. Unset = first available tab (Rosters). */
defaultMatchPageTab?: "rosters" | "join" | "action";
}
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
@@ -1133,8 +1131,6 @@ export interface User {
weaponPool: JSONColumnTypeNullable<WeaponPoolEntry[]>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<DBBoolean>;
/** User doesn't have access to SplatNet 3 to join rooms made by others */
noSplatnet: Generated<DBBoolean>;
buildSorting: JSONColumnTypeNullable<BuildSort[]>;
preferences: JSONColumnTypeNullable<UserPreferences>;
/** User creation date. Can be null because we did not always save this. */
@@ -1435,13 +1431,6 @@ export interface NotificationUserSubscription {
subscription: JSONColumnType<NotificationSubscription>;
}
export interface RoomLink {
userId: number;
url: string;
createdAt: Generated<number>;
refreshedAt: Generated<number>;
}
export const SPLATOON_ROTATION_TYPES = ["SERIES", "OPEN", "X"] as const;
export type SplatoonRotationType = (typeof SPLATOON_ROTATION_TYPES)[number];
@@ -1500,7 +1489,6 @@ export interface DB {
PlusTier: PlusTier;
PlusVote: PlusVote;
PlusVotingResult: PlusVotingResult;
RoomLink: RoomLink;
ReportedWeapon: ReportedWeapon;
Skill: Skill;
SkillTeamUser: SkillTeamUser;

View File

@@ -1,59 +0,0 @@
import { sub } from "date-fns";
import { db } from "~/db/sql";
import { actorId } from "~/features/auth/core/user.server";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
export function upsertOwn(url: string) {
return db
.insertInto("RoomLink")
.values({
userId: actorId(),
url,
})
.onConflict((oc) =>
oc.column("userId").doUpdateSet({
url,
createdAt: databaseTimestampNow(),
refreshedAt: databaseTimestampNow(),
}),
)
.execute();
}
export function findByUserIds(userIds: number[], maxAgeHours: number) {
return db
.selectFrom("RoomLink")
.select([
"RoomLink.userId",
"RoomLink.url",
"RoomLink.createdAt",
"RoomLink.refreshedAt",
])
.where("RoomLink.userId", "in", userIds)
.where(
"RoomLink.createdAt",
">=",
dateToDatabaseTimestamp(sub(new Date(), { hours: maxAgeHours })),
)
.orderBy("RoomLink.refreshedAt", "asc")
.execute();
}
export function refreshOwnTimestamp() {
return db
.updateTable("RoomLink")
.set({ refreshedAt: databaseTimestampNow() })
.where("userId", "=", actorId())
.execute();
}
export function deleteOld() {
return db
.deleteFrom("RoomLink")
.where(
"refreshedAt",
"<",
dateToDatabaseTimestamp(sub(new Date(), { hours: 2 })),
)
.executeTakeFirst();
}

View File

@@ -1,9 +1,5 @@
import { describe, expect, test } from "vitest";
import {
extractRoomLink,
findRoomLinks,
isSplatnetRoomUrl,
} from "./chat-constants";
import { findRoomLinks, isSplatnetRoomUrl } from "./chat-constants";
describe("isSplatnetRoomUrl", () => {
test("accepts canonical SplatNet share path", () => {
@@ -111,15 +107,3 @@ describe("findRoomLinks", () => {
]);
});
});
describe("extractRoomLink", () => {
test("returns first valid link", () => {
expect(extractRoomLink("hi https://s.nintendo.com/abc see you")).toBe(
"https://s.nintendo.com/abc",
);
});
test("returns null when no valid link present", () => {
expect(extractRoomLink("https://sanintendoacom.evil.tld/abc")).toBeNull();
});
});

View File

@@ -31,18 +31,6 @@ export function findRoomLinks(
return results;
}
export function extractRoomLink(text: string): string | null {
return findRoomLinks(text)[0]?.url ?? null;
}
const MATCH_ROOM_URL_PATTERN =
/^\/q\/match\/\d+$|^\/to\/\d+\/matches\/\d+$|^\/scrims\/\d+$/;
export function isMatchRoomUrl(url: string) {
const pathname = canParseUrl(url) ? new URL(url).pathname : url;
return MATCH_ROOM_URL_PATTERN.test(pathname);
}
function canParseUrl(url: string): boolean {
try {
new URL(url);

View File

@@ -107,6 +107,20 @@
opacity: 0.7;
}
.roomLinkBlock {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--s-1);
margin-block: var(--s-1);
}
.roomQrCode {
background-color: white;
padding: var(--s-2);
border-radius: var(--radius-field);
}
.roomLink {
color: var(--color-text-accent);
text-decoration: underline;

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { sub } from "date-fns";
import { SendHorizontal } from "lucide-react";
import { QRCodeSVG } from "qrcode.react";
import * as React from "react";
import { Button } from "react-aria-components";
import { useTranslation } from "react-i18next";
@@ -325,15 +326,17 @@ function MessageContents({ text }: { text: string }) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(
<a
key={i}
href={match.url}
target="_blank"
rel="noopener noreferrer"
className={styles.roomLink}
>
{match.url}
</a>,
<span key={i} className={styles.roomLinkBlock}>
<QRCodeSVG value={match.url} size={120} className={styles.roomQrCode} />
<a
href={match.url}
target="_blank"
rel="noopener noreferrer"
className={styles.roomLink}
>
{match.url}
</a>
</span>,
);
lastIndex = match.index + match.url.length;
}

View File

@@ -1,81 +0,0 @@
import { differenceInMinutes } from "date-fns";
import { useFetcher } from "react-router";
import { databaseTimestampToDate } from "~/utils/dates";
interface RoomLink {
userId: number;
url: string;
refreshedAt: number;
}
interface ResolveActiveRoomLinkArgs {
/** Room links for all match participants, sorted by `refreshedAt` ascending. */
roomLinks: ReadonlyArray<RoomLink>;
/** Database timestamp before which a link is considered stale (e.g. match start time). */
freshnessCutoff: number;
/** Viewer user id, used as fallback to surface the viewer's own stale link. */
viewerUserId?: number;
/** Members shown to resolve `hostedBy`. */
members: ReadonlyArray<{ id: number; username: string }>;
}
interface ActiveRoomLink {
joinLink?: string;
hostedBy?: string;
isStale?: boolean;
staleMinutesAgo: number;
refreshedAt?: Date;
}
/**
* Selects the room link to display for a match. Prefers the oldest link refreshed
* after the freshness cutoff (the host's confirmed room). Falls back to the
* viewer's own stale link so they can refresh it themselves.
*/
export function resolveActiveRoomLink({
roomLinks,
freshnessCutoff,
viewerUserId,
members,
}: ResolveActiveRoomLinkArgs): ActiveRoomLink {
const validRoomLink = roomLinks.find(
(rl) => rl.refreshedAt >= freshnessCutoff,
);
const ownStaleRoomLink = validRoomLink
? undefined
: roomLinks.find((rl) => rl.userId === viewerUserId);
const activeRoomLink = validRoomLink ?? ownStaleRoomLink;
return {
joinLink: activeRoomLink?.url,
hostedBy: activeRoomLink
? members.find((m) => m.id === activeRoomLink.userId)?.username
: undefined,
isStale: activeRoomLink ? !validRoomLink : undefined,
staleMinutesAgo: ownStaleRoomLink
? differenceInMinutes(
new Date(),
databaseTimestampToDate(ownStaleRoomLink.refreshedAt),
)
: 0,
refreshedAt: validRoomLink
? databaseTimestampToDate(validRoomLink.refreshedAt)
: undefined,
};
}
/** Confirms the viewer's room link by refreshing its timestamp via the central `/room` action. */
export function useConfirmRoom() {
const fetcher = useFetcher();
return {
onConfirmRoom: () => {
fetcher.submit(
{ _action: "CONFIRM" },
{ method: "post", action: "/room", encType: "application/json" },
);
},
isConfirming: fetcher.state !== "idle",
};
}

View File

@@ -1,35 +0,0 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { parseRequestPayload } from "~/utils/remix.server";
import { isSplatnetRoomUrl } from "../chat-constants";
import * as RoomLinkRepository from "../RoomLinkRepository.server";
const roomLinkSchema = z.discriminatedUnion("_action", [
z.object({
_action: z.literal("UPSERT"),
url: z.string().refine(isSplatnetRoomUrl, "Not a SplatNet room URL"),
}),
z.object({
_action: z.literal("CONFIRM"),
}),
]);
export const action = async ({ request }: ActionFunctionArgs) => {
const data = await parseRequestPayload({
request,
schema: roomLinkSchema,
});
switch (data._action) {
case "UPSERT": {
await RoomLinkRepository.upsertOwn(data.url);
break;
}
case "CONFIRM": {
await RoomLinkRepository.refreshOwnTimestamp();
break;
}
}
return null;
};

View File

@@ -17,7 +17,6 @@ import {
import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomRow";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchPage } from "~/components/match-page/MatchPage";
import { MatchPageHeader } from "~/components/match-page/MatchPageHeader";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
@@ -96,6 +95,8 @@ export default function MatchPageTestRoute() {
teamName: "Chimera",
})}
screenLegal={false}
joinPool="SQ7"
joinPass="8430"
/>
<MatchBannerBottomRow
games={[{ mode: "SZ" }, { mode: "TC" }, { mode: "RM" }]}
@@ -172,13 +173,7 @@ export default function MatchPageTestRoute() {
/>
</MatchBannerContainer>
<MatchTabs tabs={["join", "rosters", "action", "result"]}>
<MatchJoinTab
joinLink="https://app.nintendo.net/private_battle/abc123"
pool="SQ7"
pass="8430"
showNoSplatnetAlert
/>
<MatchTabs tabs={["rosters", "action", "result"]}>
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[true, false]}

View File

@@ -14,7 +14,6 @@ export async function settingsByUserId(userId: number) {
"User.vc",
"User.languages",
"User.noScreen",
"User.noSplatnet",
matchProfileWeapons(eb).as("weaponPool"),
])
.where("id", "=", userId)
@@ -49,14 +48,12 @@ export async function updateOwnMatchProfile({
languages,
weaponPool,
noScreen,
noSplatnet,
}: {
mapModePreferences: UserMapModePreferences;
vc: Tables["User"]["vc"];
languages: string[];
weaponPool: WeaponPoolItem[];
noScreen: number;
noSplatnet: number;
}) {
const userId = actorId();
const currentPreferences = (
@@ -102,7 +99,6 @@ export async function updateOwnMatchProfile({
vc,
languages: languages.length > 0 ? languages.join(",") : null,
noScreen,
noSplatnet,
})
.where("id", "=", userId)
.execute();

View File

@@ -1,4 +1,3 @@
import { sub } from "date-fns";
import { Ban, Swords } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
@@ -9,20 +8,14 @@ import {
} from "~/components/match-page/MatchBanner";
import { MatchBannerScheduledTime } from "~/components/match-page/MatchBannerScheduledTime";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { resolveRoomPass } from "~/components/match-page/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import * as Scrim from "../core/Scrim";
import type { loader } from "../loaders/scrims.$id.server";
import { SCRIM } from "../scrims-constants";
export function ScrimMatchBanner() {
const { t } = useTranslation(["scrims"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const screenLegal = !data.anyUserPrefersNoScreen;
@@ -45,17 +38,8 @@ export function ScrimMatchBanner() {
);
}
const acceptedRequest = data.post.requests[0];
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: dateToDatabaseTimestamp(
sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }),
),
viewerUserId: user?.id,
members: [...data.post.users, ...acceptedRequest.users],
});
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
const joinPool = Scrim.resolvePoolCode(data.post.id);
const joinPass = resolveRoomPass(data.post.id);
const currentMap = data.mapByMap.currentMap;
@@ -68,7 +52,7 @@ export function ScrimMatchBanner() {
mode={currentMap.mode}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
/>
</MatchBannerContainer>
);
@@ -83,7 +67,7 @@ export function ScrimMatchBanner() {
subtitle={t("scrims:banner.freeForm.subtitle")}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
/>
</MatchBannerContainer>
);

View File

@@ -1,59 +1,27 @@
import { sub } from "date-fns";
import { useTranslation } from "react-i18next";
import { useLoaderData } from "react-router";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs";
import type { TimelineMap } from "~/components/match-page/MatchTimeline";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import {
resolveActiveRoomLink,
useConfirmRoom,
} from "~/features/chat/room-link-utils";
import {
databaseTimestampToJavascriptTimestamp,
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
import { teamPage } from "~/utils/urls";
import * as Scrim from "../core/Scrim";
import type { loader } from "../loaders/scrims.$id.server";
import { SCRIM } from "../scrims-constants";
import type { ScrimPost } from "../scrims-types";
import { ScrimMatchActionTab } from "./ScrimMatchActionTab";
import { ScrimMatchStatsTab } from "./ScrimMatchStatsTab";
export function ScrimMatchTabs() {
const { t } = useTranslation(["q"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const { onConfirmRoom, isConfirming } = useConfirmRoom();
const acceptedRequest = data.post.requests[0];
const allMembers = [...data.post.users, ...acceptedRequest.users];
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: dateToDatabaseTimestamp(
sub(new Date(), { minutes: SCRIM.ROOM_LINK_FRESHNESS_MINUTES }),
),
viewerUserId: user?.id,
members: allMembers,
});
const tabs = resolveTabs(data);
return (
<MatchTabs tabs={tabs}>
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={Scrim.resolvePoolCode(data.post.id)}
pass={resolveRoomPass(data.post.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
<MatchRosterTab
minMembersPerTeam={4}
teams={[
@@ -96,7 +64,6 @@ export function ScrimMatchTabs() {
function resolveTabs(data: ReturnType<typeof useLoaderData<typeof loader>>) {
const tabs: Array<(typeof TAB_KEYS)[keyof typeof TAB_KEYS]> = [
TAB_KEYS.ROSTERS,
TAB_KEYS.JOIN,
];
if (!data.mapByMap?.locked) {

View File

@@ -1,6 +1,5 @@
import type { LoaderFunctionArgs } from "react-router";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { notFoundIfFalsy } from "../../../utils/remix.server";
@@ -31,12 +30,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const participantIds = Scrim.participantIdsListFromAccepted(post);
const [anyUserPrefersNoScreen, anyUserPrefersNoSplatnet, roomLinks] =
await Promise.all([
UserRepository.anyUserPrefersNoScreen(participantIds),
UserRepository.anyUserPrefersNoSplatnet(participantIds),
RoomLinkRepository.findByUserIds(participantIds, 3),
]);
const anyUserPrefersNoScreen =
await UserRepository.anyUserPrefersNoScreen(participantIds);
const mapByMap = await resolveMapByMap({ post, user });
@@ -52,8 +47,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
? post.chatCode
: undefined,
anyUserPrefersNoScreen,
anyUserPrefersNoSplatnet,
roomLinks,
mapByMap,
};
};

View File

@@ -19,7 +19,6 @@ export const SCRIM = {
CANCEL_REASON_MAX_LENGTH: 500,
REQUEST_MESSAGE_MAX_LENGTH: 200,
MAX_TIME_RANGE_MS: 3 * 60 * 60 * 1000, // 3 hours
ROOM_LINK_FRESHNESS_MINUTES: 30,
AUTO_CANCEL_WINDOW_HOURS: 1,
};

View File

@@ -15,8 +15,8 @@ import { MatchBannerBottomRow } from "~/components/match-page/MatchBannerBottomR
import { MatchBannerStartedAt } from "~/components/match-page/MatchBannerStartedAt";
import { MatchBannerTimer } from "~/components/match-page/MatchBannerTimer";
import { MatchBannerTopRow } from "~/components/match-page/MatchBannerTopRow";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import { databaseTimestampToDate } from "~/utils/dates";
@@ -90,16 +90,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
);
const joinPool = isParticipant ? `SQ${String(data.match.id).at(-1)}` : null;
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.createdAt,
viewerUserId: user?.id,
members: [
...data.match.groupAlpha.members,
...data.match.groupBravo.members,
],
});
const joinViaQr = Boolean(activeRoomLink.joinLink) && !activeRoomLink.isStale;
const joinPass = isParticipant ? resolveRoomPass(data.match.id) : null;
return (
<MatchBannerContainer>
@@ -112,7 +103,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
teamName: cancelRequesterName,
})}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
/>
) : (
<MatchBanner
@@ -122,7 +113,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
!data.match.groupAlpha.noScreen && !data.match.groupBravo.noScreen
}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
>
<CurrentMapVotesBadge voters={currentMap.voters} />
</MatchBanner>

View File

@@ -1,15 +1,9 @@
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs } from "~/components/match-page/MatchTabs";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import {
resolveActiveRoomLink,
useConfirmRoom,
} from "~/features/chat/room-link-utils";
import { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow } from "~/utils/dates";
@@ -28,7 +22,6 @@ import { SendouQMatchActionTab } from "./SendouQMatchActionTab";
export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
const user = useUser();
const isStaff = useHasRole("STAFF");
const { onConfirmRoom, isConfirming } = useConfirmRoom();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { t } = useTranslation(["q"]);
@@ -90,8 +83,7 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
reporterSide !== null &&
reporterSide !== userSide;
const tabs: Array<"join" | "rosters" | "action" | "result"> = ["rosters"];
if (!isLocked && isParticipant) tabs.push("join");
const tabs: Array<"rosters" | "action" | "result"> = ["rosters"];
if (showActionTab) tabs.push("action");
if (isLocked || hasReportedMaps) tabs.push("result");
@@ -99,18 +91,6 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
? ["action"]
: undefined;
const allMembers = [
...data.match.groupAlpha.members,
...data.match.groupBravo.members,
];
const activeRoomLink = resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.createdAt,
viewerUserId: user?.id,
members: allMembers,
});
const ownGroup =
userSide === "ALPHA"
? data.match.groupAlpha
@@ -146,16 +126,6 @@ export function SendouQMatchTabs({ data }: { data: SendouQMatchLoaderData }) {
) : null}
</MatchResultTab>
) : null}
{!isLocked && isParticipant ? (
<MatchJoinTab
{...activeRoomLink}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={`SQ${String(data.match.id).at(-1)}`}
pass={resolveRoomPass(data.match.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
) : null}
<MatchRosterTab
minMembersPerTeam={4}
canEditSubbedOut={[false, false]}

View File

@@ -1,13 +1,11 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
@@ -31,22 +29,16 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const isStaff = user?.roles.includes("STAFF") ?? false;
const isParticipant = Boolean(user && matchUsers.includes(user.id));
const canSeeRoomLinks = isStaff || isParticipant;
const [privateNotes, roomLinks, anyUserPrefersNoSplatnet, reportedWeapons] =
await Promise.all([
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
canSeeRoomLinks ? RoomLinkRepository.findByUserIds(matchUsers, 3) : [],
UserRepository.anyUserPrefersNoSplatnet(matchUsers),
ReportedWeaponRepository.findByMatchId(matchId),
]);
const [privateNotes, reportedWeapons] = await Promise.all([
user ? PrivateUserNoteRepository.ownNotes(matchUsers) : undefined,
ReportedWeaponRepository.findByMatchId(matchId),
]);
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
return {
match,
roomLinks,
anyUserPrefersNoSplatnet,
reportedWeapons,
isOffSeason: Seasons.current() === null,
chatCode: (() => {

View File

@@ -58,12 +58,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "UPDATE_DEFAULT_MATCH_PAGE_TAB": {
await UserRepository.updateOwnPreferences({
defaultMatchPageTab: data.newValue,
});
break;
}
case "UPDATE_MATCH_PROFILE": {
await MatchProfileRepository.updateOwnMatchProfile({
mapModePreferences: data.mapModePreferences,
@@ -71,7 +65,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
languages: data.languages,
weaponPool: data.weaponPool,
noScreen: Number(data.noScreen),
noSplatnet: Number(data.noSplatnet),
});
break;
}

View File

@@ -30,7 +30,6 @@ export function MatchProfileTab() {
vc: matchProfile.vc ?? "NO",
languages: matchProfile.languages ?? [],
noScreen: Boolean(matchProfile.noScreen),
noSplatnet: Boolean(matchProfile.noSplatnet),
}}
revalidateRoot
>
@@ -50,7 +49,6 @@ export function MatchProfileTab() {
<FormField name="weaponPool" />
<FormField name="vc" />
<FormField name="languages" />
<FormField name="noSplatnet" />
<FormField name="noScreen" />
</>
)}

View File

@@ -9,7 +9,6 @@ import { Config } from "~/config";
import { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form/SendouForm";
import {
defaultMatchPageTabSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
@@ -58,18 +57,6 @@ export function PreferencesTab() {
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={defaultMatchPageTabSchema}
defaultValues={{
newValue: user.preferences.defaultMatchPageTab ?? "rosters",
}}
autoSubmit
revalidateRoot
fullWidth
hideRequiredIndicator
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</div>
);

View File

@@ -65,10 +65,6 @@ export const updateMatchProfileSchema = z.object({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
noSplatnet: toggle({
label: "labels.noSplatnet",
bottomText: "bottomTexts.noScreen",
}),
noScreen: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",

View File

@@ -1,11 +1,5 @@
import { z } from "zod";
import {
customField,
radioGroup,
select,
stringConstant,
toggle,
} from "~/form/fields";
import { customField, select, stringConstant, toggle } from "~/form/fields";
import { themeInputSchema } from "~/utils/zod";
const customThemeSchema = z.object({
@@ -54,18 +48,6 @@ const weaponReportDefaultOpenSchema = z.object({
newValue: z.boolean(),
});
export const defaultMatchPageTabSchema = z.object({
_action: stringConstant("UPDATE_DEFAULT_MATCH_PAGE_TAB"),
newValue: radioGroup({
label: "labels.defaultMatchPageTab",
items: [
{ value: "rosters", label: "options.defaultMatchPageTab.rosters" },
{ value: "join", label: "options.defaultMatchPageTab.join" },
{ value: "action", label: "options.defaultMatchPageTab.action" },
],
}),
});
export const settingsEditSchema = z.union([
customThemeSchema,
disableBuildAbilitySortingSchema,
@@ -73,5 +55,4 @@ export const settingsEditSchema = z.union([
spoilerFreeModeSchema,
clockFormatSchema,
weaponReportDefaultOpenSchema,
defaultMatchPageTabSchema,
]);

View File

@@ -55,10 +55,8 @@ export function TournamentMatchBanner({
teamsMissingActiveRoster,
matchIsLocked,
joinPool,
activeRoomLink,
joinPass,
} = useMatch();
const joinViaQr =
Boolean(activeRoomLink?.joinLink) && !activeRoomLink?.isStale;
const opponentOne = data.match.opponentOne;
const opponentTwo = data.match.opponentTwo;
@@ -152,7 +150,7 @@ export function TournamentMatchBanner({
})}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
testId="active-roster-needed-text"
/>
) : pickBanBanner ? (
@@ -162,7 +160,7 @@ export function TournamentMatchBanner({
subtitle={pickBanBanner.subtitle}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
/>
) : currentMap ? (
<MatchBanner
@@ -170,7 +168,7 @@ export function TournamentMatchBanner({
mode={currentMap.mode}
screenLegal={screenLegal}
joinPool={joinPool}
joinViaQr={joinViaQr}
joinPass={joinPass}
>
<CurrentMapPickInfo
source={currentMap.source}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "react-i18next";
import { useFetcher } from "react-router";
import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs";
@@ -8,9 +7,7 @@ import type {
TimelineMap,
TimelinePickBanEvent,
} from "~/components/match-page/MatchTimeline";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import { useConfirmRoom } from "~/features/chat/room-link-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
import { tournamentTeamToActiveRosterUserIds } from "~/features/tournament-bracket/tournament-bracket-utils";
@@ -88,9 +85,6 @@ export function TournamentMatchTabs({
isOngoing={!data.matchIsOver && data.results.length > 0}
/>
) : null}
{tabs.includes(TAB_KEYS.JOIN) ? (
<TournamentMatchJoinTab data={data} />
) : null}
<TournamentMatchRosterTab data={data} />
{tabs.includes(TAB_KEYS.ACTION) ? (
isPickBanStep && turnOfResult ? (
@@ -312,30 +306,6 @@ function slotOfEvent({
}
}
function TournamentMatchJoinTab({ data }: { data: TournamentMatchLoaderData }) {
const { onConfirmRoom, isConfirming } = useConfirmRoom();
const {
teams: [teamOne, teamTwo],
joinPool,
activeRoomLink,
} = useMatch();
if (!teamOne || !teamTwo || !joinPool || !activeRoomLink) return null;
const hostingTeam = resolveHostingTeam([teamOne, teamTwo]);
return (
<MatchJoinTab
{...activeRoomLink}
hostedBy={activeRoomLink.hostedBy ?? hostingTeam.name}
onConfirmRoom={onConfirmRoom}
isConfirming={isConfirming}
pool={joinPool}
pass={resolveRoomPass(hostingTeam.id)}
showNoSplatnetAlert={data.anyUserPrefersNoSplatnet}
/>
);
}
function TournamentMatchRosterTab({
data,
}: {
@@ -351,6 +321,9 @@ function TournamentMatchRosterTab({
const tbdTeam = { defaultName: t("tournament:match.tbd"), members: [] };
const hostingTeamId =
teamOne && teamTwo ? resolveHostingTeam([teamOne, teamTwo]).id : null;
return (
<MatchRosterTab
minMembersPerTeam={tournament.minMembersPerTeam}
@@ -404,6 +377,7 @@ function TournamentMatchRosterTab({
})),
subbedOut,
seed: team.seed,
isHost: hostingTeamId === team.id,
};
}

View File

@@ -3,7 +3,6 @@ import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import { chatAccessible } from "~/features/chat/chat-utils";
import * as RoomLinkRepository from "~/features/chat/RoomLinkRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
@@ -218,16 +217,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
(isParticipant || tournament.isOrganizerOrStreamer(user)) &&
!isLeagueRoundLocked(tournament, match.roundId);
const [roomLinks, anyUserPrefersNoSplatnet] = canJoin
? await Promise.all([
RoomLinkRepository.findByUserIds(
match.players.map((p) => p.id),
3,
),
UserRepository.anyUserPrefersNoSplatnet(match.players.map((p) => p.id)),
])
: ([[], false] as const);
return {
match: hasPermsToSeeChat ? match : { ...match, chatCode: undefined },
results,
@@ -238,8 +227,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
noScreen,
chatCode: visibleChatCode,
canJoin,
roomLinks,
anyUserPrefersNoSplatnet,
pickBanEventCount: pickBanEvents.length,
pickBanEvents: pickBanEvents.map((e) => ({
type: e.type,

View File

@@ -1,7 +1,7 @@
import * as React from "react";
import { TAB_KEYS } from "~/components/match-page/MatchTabs";
import { resolveRoomPass } from "~/components/match-page/utils";
import { useUser } from "~/features/auth/core/user";
import { resolveActiveRoomLink } from "~/features/chat/room-link-utils";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { isLeagueRoundLocked } from "~/features/tournament/tournament-utils";
import * as PickBan from "~/features/tournament-bracket/core/PickBan";
@@ -13,8 +13,6 @@ import {
import type { TournamentMatchLoaderData } from "./loaders/to.$id.matches.$mid.server";
import { matchIsLocked, resolveHostingTeam } from "./tournament-match-utils";
type ActiveRoomLink = ReturnType<typeof resolveActiveRoomLink>;
export type MatchPageTeam = NonNullable<ReturnType<Tournament["teamById"]>>;
export type MatchTabKey = (typeof TAB_KEYS)[keyof typeof TAB_KEYS];
@@ -35,7 +33,7 @@ type MatchPageContextValue = {
isPickBanStep: boolean;
matchIsLocked: boolean;
joinPool: string | null;
activeRoomLink: ActiveRoomLink | null;
joinPass: string | null;
};
const MatchPageContext = React.createContext<MatchPageContextValue | null>(
@@ -101,16 +99,7 @@ export function MatchPageProvider({
scores,
});
const joinPool = resolveJoinPool({ tournament, data, teams });
const activeRoomLink = data.canJoin
? resolveActiveRoomLink({
roomLinks: data.roomLinks,
freshnessCutoff: data.match.startedAt ?? 0,
viewerUserId: user?.id,
members: data.match.players,
})
: null;
const joinInfo = resolveJoinInfo({ tournament, data, teams });
const tabs = resolveVisibleTabs({
canReportScore: tournament.canReportScore({
@@ -119,7 +108,6 @@ export function MatchPageProvider({
}),
canReportWeapons:
isParticipant && tournament.weaponReportingOpen && hasReportedMaps,
canJoin: data.canJoin,
hasCurrentMap: Boolean(currentMap),
hasMissingActiveRoster: teamsMissingActiveRoster.length > 0,
hasReportedMaps,
@@ -144,8 +132,8 @@ export function MatchPageProvider({
turnOfResult,
isPickBanStep,
matchIsLocked: lockedForCast,
joinPool,
activeRoomLink,
joinPool: joinInfo?.pool ?? null,
joinPass: joinInfo?.pass ?? null,
}}
>
{children}
@@ -164,7 +152,6 @@ export function useMatch() {
function resolveVisibleTabs({
canReportScore,
canReportWeapons,
canJoin,
hasCurrentMap,
hasMissingActiveRoster,
hasReportedMaps,
@@ -176,7 +163,6 @@ function resolveVisibleTabs({
}: {
canReportScore: boolean;
canReportWeapons: boolean;
canJoin: boolean;
hasCurrentMap: boolean;
hasMissingActiveRoster: boolean;
hasReportedMaps: boolean;
@@ -188,9 +174,6 @@ function resolveVisibleTabs({
}): MatchTabKey[] {
const tabs: MatchTabKey[] = [TAB_KEYS.ROSTERS];
if (canJoin) {
tabs.push(TAB_KEYS.JOIN);
}
if (
!leagueRoundLocked &&
(isPickBanStep ||
@@ -214,7 +197,7 @@ function resolveVisibleTabs({
return tabs;
}
function resolveJoinPool({
function resolveJoinInfo({
tournament,
data,
teams,
@@ -222,7 +205,7 @@ function resolveJoinPool({
tournament: ReturnType<typeof useTournament>;
data: TournamentMatchLoaderData;
teams: [MatchPageTeam | null, MatchPageTeam | null];
}): string | null {
}): { pool: string; pass: string } | null {
if (!data.canJoin) return null;
const [teamOne, teamTwo] = teams;
@@ -254,7 +237,10 @@ function resolveJoinPool({
: undefined,
});
return `${poolCode.prefix}${poolCode.suffix}`;
return {
pool: `${poolCode.prefix}${poolCode.suffix}`,
pass: resolveRoomPass(hostingTeam.id),
};
}
function resolveTeamsMissingActiveRoster(

View File

@@ -1312,21 +1312,6 @@ export async function anyUserPrefersNoScreen(
return Boolean(result);
}
export async function anyUserPrefersNoSplatnet(
userIds: number[],
): Promise<boolean> {
if (userIds.length === 0) return false;
const result = await db
.selectFrom("User")
.select("User.noSplatnet")
.where("User.id", "in", userIds)
.where("User.noSplatnet", "=", 1)
.executeTakeFirst();
return Boolean(result);
}
export async function socialLinksByUserId(userId: number) {
const user = await db
.selectFrom("User")

View File

@@ -272,7 +272,6 @@ export default [
route("/admin", "features/admin/routes/admin.tsx"),
route("/admin/streams", "features/admin/routes/admin.streams.tsx"),
route("/api/chat-users", "features/chat/routes/api.chat-users.ts"),
route("/room", "features/chat/routes/room.ts"),
route("/api", "features/api/routes/api.tsx"),
...prefix("/a", [

View File

@@ -1,11 +0,0 @@
import * as RoomLinkRepository from "../features/chat/RoomLinkRepository.server";
import { logger } from "../utils/logger";
import { Routine } from "./routine.server";
export const DeleteOldRoomLinksRoutine = new Routine({
name: "DeleteOldRoomLinks",
func: async () => {
const { numDeletedRows } = await RoomLinkRepository.deleteOld();
logger.info(`Deleted ${numDeletedRows} old room links`);
},
});

View File

@@ -3,7 +3,6 @@ import { CloseExpiredContinueVotesRoutine } from "./closeExpiredContinueVotes";
import { DeleteObsoleteMatchVodsRoutine } from "./deleteObsoleteMatchVods";
import { DeleteOldExternalStreamsRoutine } from "./deleteOldExternalStreams";
import { DeleteOldNotificationsRoutine } from "./deleteOldNotifications";
import { DeleteOldRoomLinksRoutine } from "./deleteOldRoomLinks";
import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs";
import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags";
import { NotifyCheckInStartRoutine } from "./notifyCheckInStart";
@@ -32,7 +31,6 @@ export const everyHourAt30 = [
SetOldGroupsAsInactiveRoutine,
UpdatePatreonDataRoutine,
CloseExpiredContinueVotesRoutine,
DeleteOldRoomLinksRoutine,
DeleteOldExternalStreamsRoutine,
];

Binary file not shown.

View File

@@ -33,12 +33,11 @@ const TAB_LABELS = {
admin: "Admin",
result: "Result",
rosters: "Rosters",
join: "Join",
} as const;
export const goToTab = async (
page: Page,
tab: "action" | "admin" | "result" | "rosters" | "join",
tab: "action" | "admin" | "result" | "rosters",
) => {
// When teams have more members than the minimum, the action tab is hidden
// until each team's active roster is locked in via the rosters tab. Auto-set

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -12,7 +12,6 @@ import {
startBracket,
test,
} from "./helpers/playwright";
import { goToTab } from "./helpers/tournament-match";
const TOURNAMENT_ID = 2;
@@ -146,8 +145,6 @@ test.describe("Tournament staff", () => {
url: tournamentMatchPage({ tournamentId: TOURNAMENT_ID, matchId: 2 }),
});
await goToTab(page, "join");
await expect(roomPassSelector).toBeVisible();
});
});

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Bliv medlem",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Note: Hvis du ændrer dit holds navn, så kan andre hold overtage det tidligere holdnavn og URL-adresse.",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Kaptajn",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Frontlinje",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Beitreten",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Hinweis: Wenn du den Namen deines Teams änderst, können andere Teams den Namen und und die URL für sich beanspruchen.",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Kapitän",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "Outlined",
"actions.noOutline": "No outline",
"actions.join": "Join",
"host": "Host",
"actions.nevermind": "Nevermind",
"actions.clickHere": "Click here",
"actions.goBack": "Go back",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "Custom role",
"labels.teamMemberRoleType": "Role type",
"labels.clockFormat": "Clock format",
"labels.defaultMatchPageTab": "Default match page tab",
"labels.disableBuildAbilitySorting": "Builds: Disable automatic ability sorting",
"labels.disallowScrimPickupsFromUntrusted": "Disallow scrim pickups from non-friends",
"labels.noScreen": "[Accessibility] Avoid Splattercolor Screen",
"labels.noSplatnet": "No SplatNet access",
"labels.spoilerFreeMode": "Spoiler-free mode",
"bottomTexts.imageModeration": "Unless you're a Supporter, newly uploaded images are shown publicly only after a moderator has checked them.",
"bottomTexts.name": "Note that if you change your team's name then someone else can claim the name and URL for their team",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "Automatic",
"options.clockFormat.24h": "24-hour",
"options.clockFormat.12h": "12-hour",
"options.defaultMatchPageTab.rosters": "Rosters",
"options.defaultMatchPageTab.join": "Join",
"options.defaultMatchPageTab.action": "Action",
"options.teamMemberRole.CAPTAIN": "Captain",
"options.teamMemberRole.CO_CAPTAIN": "Co-Captain",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Pool",
"match.password.short": "Pass",
"match.hostedBy": "Hosted by",
"match.noSplatnetWarning": "One or more players don't have Splatnet, remember to use the pool and pass",
"match.room.noRoomHint": "Send the SplatNet room link to the chat or use the Share feature in the SplatNet3 app",
"match.room.stalePrompt": "Your room from {{minutes}} minutes ago still up?",
"match.room.confirm": "Confirm",
"match.cancelMatch": "Cancel match",
"match.canceled": "Match canceled",
"match.canceled.detail": "Set was canceled as requested by {{requester}} and accepted by {{accepter}}",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "Con borde",
"actions.noOutline": "Sin borde",
"actions.join": "Unirse",
"host": "",
"actions.nevermind": "Olvídalo",
"actions.clickHere": "Haz clic aquí",
"actions.goBack": "Volver atrás",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "Formato de hora",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "Builds: Desactivar orden automático de potenciadores",
"labels.disallowScrimPickupsFromUntrusted": "No permitir invitaciones de usuarios no verificados",
"labels.noScreen": "[Accesibilidad] Evitar Pantintalla",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "Automático",
"options.clockFormat.24h": "24 horas",
"options.clockFormat.12h": "12 horas",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitán",
"options.teamMemberRole.CO_CAPTAIN": "Co-Capitán",
"options.teamMemberRole.FRONTLINE": "Delantero",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Grupo",
"match.password.short": "Codigo",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Cancelar partido",
"match.canceled": "Partido cancelado",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Unirse",
"host": "",
"actions.nevermind": "Cancelar",
"actions.clickHere": "Haga click aquí",
"actions.goBack": "Volver",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Nota que si cambias el nombre de tu equipo, el nombre y la URL estarán libres para que otro equipo los tome",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitán",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Delantero",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Canal",
"match.password.short": "Contraseña",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Cancelar partido",
"match.canceled": "Partido cancelado",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Joindre",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitaine",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "Outlined",
"actions.noOutline": "No outline",
"actions.join": "Joindre",
"host": "",
"actions.nevermind": "Laisser tomber",
"actions.clickHere": "Click ici",
"actions.goBack": "Retourner en arrière",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Veuillez noter que si vous changer le nom de l'équipe, quelqu'un d'autre pourra s'emparer de l'ancien nom et URL",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitaine",
"options.teamMemberRole.CO_CAPTAIN": "Co-capitaine",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Canal",
"match.password.short": "Mot de passe",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Annuler match",
"match.canceled": "Match annulé",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "הצטרפות",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "שימו לב שאם תשנו את שם הצוות שלכם, מישהו אחר יוכל לקחת בעלות על השם ועל כתובת האתר עבור הצוות שלו",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "קפטן",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "קו החזית",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "Contornato",
"actions.noOutline": "Nessun contorno",
"actions.join": "Entra",
"host": "",
"actions.nevermind": "Non importa",
"actions.clickHere": "Clicca qui",
"actions.goBack": "Indietro",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Nota che se cambi il nome del team, qualcun altro può assumere nome e URL per il proprio team",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitano",
"options.teamMemberRole.CO_CAPTAIN": "Co-Capitano",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Pool",
"match.password.short": "Pass",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Cancella match",
"match.canceled": "Match cancellato",
"match.canceled.detail": "",

View File

@@ -2,8 +2,7 @@
"patreon": "Patreon での sendou.ink サポーター",
"patreon+": "Patoreon での sendou.ink サポーター+",
"xp": "{{xpText}} に到達したことで獲得",
"tournament_one": "{{tournament}}を勝利したことで獲得",
"tournament_other": "{{tournament}}を(×{{count}})回勝利したことで獲得",
"tournament": "{{tournament}}を勝利したことで獲得",
"forYourEvent": "イベント専用のバッジ?",
"managedBy": "<0></0> によって管理されています",
"madeBy": "製作者: <0></0>",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "アウトラインあり",
"actions.noOutline": "アウトラインなし",
"actions.join": "参加する",
"host": "",
"actions.nevermind": "やーめた",
"actions.clickHere": "ここをクリック",
"actions.goBack": "戻る",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "注意: チーム名を変更した場合、他のプレイヤーが変更前の名前と URL を別のチームのために使用することができるようになります。",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "キャプテン",
"options.teamMemberRole.CO_CAPTAIN": "キャプテン補佐",
"options.teamMemberRole.FRONTLINE": "前衛",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "ヘルプデスク",
"match.pool": "プール",
"match.password.short": "通過",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "マッチをキャンセルする",
"match.canceled": "マッチをキャンセルしました",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "참여하기",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Dołącz",
"host": "",
"actions.nevermind": "",
"actions.clickHere": "",
"actions.goBack": "",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Uwaga: Jeśli zmienisz imię drużyny, ktoś inny może użyć twoje stare imię i URL",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Kapitan",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Frontline",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "",
"match.pool": "",
"match.password.short": "",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "",
"match.canceled": "",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "",
"actions.noOutline": "",
"actions.join": "Entrar",
"host": "",
"actions.nevermind": "Deixa pra lá...",
"actions.clickHere": "Clique aqui",
"actions.goBack": "Voltar",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Lembre-se que se você mudar o nome do seu time, alguém pode resgatar o nome e o URL para o time dele(a)",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Capitão(ã)",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "Linha de frente (frontline)",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Helpdesk",
"match.pool": "Pool",
"match.password.short": "Senha",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Cancelar partida",
"match.canceled": "Partida cancelada",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "Обводка",
"actions.noOutline": "Без обводки",
"actions.join": "Присоединиться",
"host": "",
"actions.nevermind": "Отмена",
"actions.clickHere": "Нажмите здесь",
"actions.goBack": "Назад",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "Обратите внимание, что если вы измените название команды, то кто-то другой может забрать себе URL и название для своей команды",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "Капитан",
"options.teamMemberRole.CO_CAPTAIN": "Со-капитан",
"options.teamMemberRole.FRONTLINE": "Фронтлайн",

View File

@@ -124,11 +124,6 @@
"match.helpdesk": "Служба поддержки",
"match.pool": "Канал",
"match.password.short": "Пароль",
"match.hostedBy": "",
"match.noSplatnetWarning": "",
"match.room.noRoomHint": "",
"match.room.stalePrompt": "",
"match.room.confirm": "",
"match.cancelMatch": "Отменить матч",
"match.canceled": "Матч был отменён",
"match.canceled.detail": "",

View File

@@ -139,6 +139,7 @@
"actions.outlined": "描边",
"actions.noOutline": "无描边",
"actions.join": "加入",
"host": "",
"actions.nevermind": "反悔",
"actions.clickHere": "点击此处",
"actions.goBack": "返回",

View File

@@ -12,11 +12,9 @@
"labels.teamMemberCustomRole": "",
"labels.teamMemberRoleType": "",
"labels.clockFormat": "",
"labels.defaultMatchPageTab": "",
"labels.disableBuildAbilitySorting": "",
"labels.disallowScrimPickupsFromUntrusted": "",
"labels.noScreen": "",
"labels.noSplatnet": "",
"labels.spoilerFreeMode": "",
"bottomTexts.imageModeration": "",
"bottomTexts.name": "请注意如果您更改了队名那么其他人便可以使用之前的队名和URL了。",
@@ -29,9 +27,6 @@
"options.clockFormat.auto": "",
"options.clockFormat.24h": "",
"options.clockFormat.12h": "",
"options.defaultMatchPageTab.rosters": "",
"options.defaultMatchPageTab.join": "",
"options.defaultMatchPageTab.action": "",
"options.teamMemberRole.CAPTAIN": "队长",
"options.teamMemberRole.CO_CAPTAIN": "",
"options.teamMemberRole.FRONTLINE": "前排",

Some files were not shown because too many files have changed in this diff Show More