mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-10 21:26:08 -05:00
Merge branch 'main' into bracket-engine-refactor
This commit is contained in:
@@ -27,8 +27,8 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
|
||||
// Discord webhook new user reports are posted to (skipped when unset)
|
||||
USER_REPORT_DISCORD_WEBHOOK_URL=
|
||||
// Discord webhook mod events (user reports, bans) are posted to (skipped when unset)
|
||||
MOD_DISCORD_WEBHOOK_URL=
|
||||
|
||||
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
|
||||
SKALOP_TOKEN=secret
|
||||
|
||||
@@ -78,3 +78,39 @@
|
||||
outline: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.weekSelection {
|
||||
& .grid {
|
||||
/* the week band has to run unbroken across the row, including the empty cells of a month's first and last week */
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
& tr:hover td,
|
||||
& tr:has([data-selected]) td {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
& tr:hover td:first-child,
|
||||
& tr:has([data-selected]) td:first-child {
|
||||
border-start-start-radius: var(--radius-field);
|
||||
border-end-start-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
& tr:hover td:last-child,
|
||||
& tr:has([data-selected]) td:last-child {
|
||||
border-start-end-radius: var(--radius-field);
|
||||
border-end-end-radius: var(--radius-field);
|
||||
}
|
||||
|
||||
& .cell {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* the week is what gets selected, so the day it was picked from is not called out */
|
||||
&[data-selected] {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,22 @@ import styles from "./Calendar.module.css";
|
||||
export interface SendouCalendarProps<T extends DateValue>
|
||||
extends CalendarProps<T> {
|
||||
className?: string;
|
||||
/** Highlights the whole week row rather than a single day, for pickers where choosing a day means choosing the week it belongs to. */
|
||||
weekSelection?: boolean;
|
||||
}
|
||||
|
||||
export function SendouCalendar<T extends DateValue>({
|
||||
className,
|
||||
weekSelection,
|
||||
...rest
|
||||
}: SendouCalendarProps<T>) {
|
||||
return (
|
||||
<Calendar className={clsx(className, styles.root)} {...rest}>
|
||||
<Calendar
|
||||
className={clsx(className, styles.root, {
|
||||
[styles.weekSelection]: weekSelection,
|
||||
})}
|
||||
{...rest}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<Button slot="previous" className={styles.navButton}>
|
||||
<ChevronLeft className={styles.navIcon} />
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SENDOU_LOVE_EMOJI_PATH,
|
||||
SUPPORT_PAGE,
|
||||
userPage,
|
||||
WELCOME_PAGE,
|
||||
} from "~/utils/urls";
|
||||
|
||||
declare const __GIT_COMMIT__: string;
|
||||
@@ -37,6 +38,7 @@ export function Footer() {
|
||||
<div className={styles.linkList}>
|
||||
<Link to={CONTRIBUTIONS_PAGE}>{t("pages.contributors")}</Link>
|
||||
<Link to={FAQ_PAGE}>{t("pages.faq")}</Link>
|
||||
<Link to={WELCOME_PAGE}>{t("pages.welcome")}</Link>
|
||||
<Link to={API_PAGE}>{t("pages.api")}</Link>
|
||||
{showPrivacySettings ? <div data-fuse-privacy-tool /> : null}
|
||||
</div>
|
||||
|
||||
@@ -326,6 +326,7 @@ export interface GroupMatch {
|
||||
memento: JSONColumnTypeNullable<ParsedMemento>;
|
||||
cancelRequestedByUserId: number | null;
|
||||
cancelAcceptedByUserId: number | null;
|
||||
noScreen: Generated<DBBoolean>;
|
||||
}
|
||||
|
||||
export interface GroupMatchContinueVote {
|
||||
|
||||
@@ -8,12 +8,17 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToast,
|
||||
notFoundIfFalsy,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { _action, actualNumber, friendCode } from "~/utils/zod";
|
||||
import {
|
||||
sendUserBannedWebhook,
|
||||
sendUserUnbannedWebhook,
|
||||
} from "../core/discord-webhook.server";
|
||||
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
@@ -124,21 +129,37 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
case "BAN_USER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
const bannedUser = notFoundIfFalsy(
|
||||
await UserRepository.findLeanById(data.user),
|
||||
);
|
||||
const banExpiresAt = data.duration ? new Date(data.duration) : null;
|
||||
|
||||
await AdminRepository.banUser({
|
||||
bannedReason: data.reason ?? null,
|
||||
userId: data.user,
|
||||
banned: data.duration ? new Date(data.duration) : 1,
|
||||
banned: banExpiresAt ?? 1,
|
||||
bannedByUserId: user.id,
|
||||
});
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
sendUserBannedWebhook({
|
||||
bannedUser,
|
||||
bannedBy: user,
|
||||
reason: data.reason ?? null,
|
||||
expiresAt: banExpiresAt,
|
||||
});
|
||||
|
||||
message = "User banned";
|
||||
break;
|
||||
}
|
||||
case "UNBAN_USER": {
|
||||
requireRole("STAFF");
|
||||
|
||||
const unbannedUser = notFoundIfFalsy(
|
||||
await UserRepository.findLeanById(data.user),
|
||||
);
|
||||
|
||||
await AdminRepository.unbanUser({
|
||||
userId: data.user,
|
||||
unbannedByUserId: user.id,
|
||||
@@ -146,6 +167,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
|
||||
await refreshBannedCache();
|
||||
|
||||
sendUserUnbannedWebhook({
|
||||
unbannedUser,
|
||||
unbannedBy: user,
|
||||
});
|
||||
|
||||
message = "User unbanned";
|
||||
break;
|
||||
}
|
||||
|
||||
70
app/features/admin/core/discord-webhook.server.ts
Normal file
70
app/features/admin/core/discord-webhook.server.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
sendModDiscordWebhook,
|
||||
truncateEmbedValue,
|
||||
userAdminPageLink,
|
||||
userPageLink,
|
||||
type WebhookUser,
|
||||
} from "~/modules/discord-webhook.server";
|
||||
|
||||
/**
|
||||
* Posts a rich embed about a user getting banned to the mod channel Discord webhook.
|
||||
* Fire-and-forget (see `sendModDiscordWebhook`).
|
||||
*/
|
||||
export function sendUserBannedWebhook(args: {
|
||||
bannedUser: WebhookUser;
|
||||
bannedBy: WebhookUser;
|
||||
reason: string | null;
|
||||
/** When the ban ends, null when the ban has no end date */
|
||||
expiresAt: Date | null;
|
||||
}) {
|
||||
sendModDiscordWebhook({
|
||||
title: "User banned",
|
||||
fields: [
|
||||
{
|
||||
name: "Banned user",
|
||||
value: userAdminPageLink(args.bannedUser),
|
||||
},
|
||||
{
|
||||
name: "Banned by",
|
||||
value: userPageLink(args.bannedBy),
|
||||
},
|
||||
{
|
||||
name: "Expires",
|
||||
value: args.expiresAt
|
||||
? `<t:${Math.floor(args.expiresAt.getTime() / 1000)}:f>`
|
||||
: "No end date",
|
||||
},
|
||||
...(args.reason
|
||||
? [
|
||||
{
|
||||
name: "Reason",
|
||||
value: truncateEmbedValue(args.reason),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a rich embed about a user getting unbanned to the mod channel Discord webhook.
|
||||
* Fire-and-forget (see `sendModDiscordWebhook`).
|
||||
*/
|
||||
export function sendUserUnbannedWebhook(args: {
|
||||
unbannedUser: WebhookUser;
|
||||
unbannedBy: WebhookUser;
|
||||
}) {
|
||||
sendModDiscordWebhook({
|
||||
title: "User unbanned",
|
||||
fields: [
|
||||
{
|
||||
name: "Unbanned user",
|
||||
value: userAdminPageLink(args.unbannedUser),
|
||||
},
|
||||
{
|
||||
name: "Unbanned by",
|
||||
value: userPageLink(args.unbannedBy),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -86,8 +86,8 @@ export const REG_CLOSES_AT_OPTIONS = [
|
||||
|
||||
export type RegClosesAtOption = (typeof REG_CLOSES_AT_OPTIONS)[number];
|
||||
|
||||
/** How many days are shown at the /calendar page at a time */
|
||||
export const DAYS_SHOWN_AT_A_TIME = 4;
|
||||
/** How many days are shown at the /calendar page at a time (full week from Monday to Sunday) */
|
||||
export const DAYS_SHOWN_AT_A_TIME = 7;
|
||||
|
||||
/** Tags not shown on the tournament cards */
|
||||
export const EXCLUDED_TAGS: Array<CalendarEventTag> = ["CARDS", "SR"];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { addDays, addWeeks, startOfWeek, subWeeks } from "date-fns";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { allTruthy } from "~/utils/arrays";
|
||||
@@ -174,67 +175,30 @@ function eventStartedInThePast(
|
||||
}
|
||||
|
||||
export function daysForCalendar(currentDate?: DayMonthYear) {
|
||||
type DaysArray = Array<DayMonthYear>;
|
||||
|
||||
const previous: DaysArray = [];
|
||||
const shown: DaysArray = [];
|
||||
const next: DaysArray = [];
|
||||
|
||||
const startDate = () =>
|
||||
currentDate
|
||||
? new Date(currentDate.year, currentDate.month, currentDate.day)
|
||||
: new Date();
|
||||
|
||||
const currentDayMonthYear = () => {
|
||||
const now = startDate();
|
||||
|
||||
return {
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
};
|
||||
};
|
||||
|
||||
let now = startDate();
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
shown.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
now.setDate(now.getDate() + 1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
next.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
now.setDate(now.getDate() + 1);
|
||||
}
|
||||
|
||||
now = startDate();
|
||||
|
||||
for (let i = 0; i < DAYS_SHOWN_AT_A_TIME; i++) {
|
||||
now.setDate(now.getDate() - 1);
|
||||
|
||||
previous.push({
|
||||
day: now.getDate(),
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
}
|
||||
previous.reverse();
|
||||
const anchor = currentDate
|
||||
? new Date(currentDate.year, currentDate.month, currentDate.day)
|
||||
: new Date();
|
||||
const weekStart = startOfWeek(anchor, { weekStartsOn: 1 });
|
||||
|
||||
return {
|
||||
previous,
|
||||
shown,
|
||||
next,
|
||||
current: currentDayMonthYear(),
|
||||
previous: weekDays(subWeeks(weekStart, 1)),
|
||||
shown: weekDays(weekStart),
|
||||
next: weekDays(addWeeks(weekStart, 1)),
|
||||
current: dateToDayMonthYear(anchor),
|
||||
};
|
||||
}
|
||||
|
||||
function weekDays(weekStart: Date): Array<DayMonthYear> {
|
||||
return Array.from({ length: DAYS_SHOWN_AT_A_TIME }, (_, i) =>
|
||||
dateToDayMonthYear(addDays(weekStart, i)),
|
||||
);
|
||||
}
|
||||
|
||||
function dateToDayMonthYear(date: Date): DayMonthYear {
|
||||
return {
|
||||
day: date.getDate(),
|
||||
month: date.getMonth(),
|
||||
year: date.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { add, sub } from "date-fns";
|
||||
import { add, startOfWeek, sub } from "date-fns";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import type { UserPreferences } from "~/db/tables";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
@@ -28,10 +28,11 @@ export const loader = async (args: LoaderFunctionArgs) => {
|
||||
).getTime()
|
||||
: Date.now();
|
||||
|
||||
const weekStart = startOfWeek(new Date(date), { weekStartsOn: 1 });
|
||||
const events = await CalendarRepository.findAllBetweenTwoTimestamps({
|
||||
// add a bit of tolerance to the timestamps to account for timezones
|
||||
startTime: sub(new Date(date), { hours: 24 }),
|
||||
endTime: add(new Date(date), { days: DAYS_SHOWN_AT_A_TIME + 1 }),
|
||||
startTime: sub(weekStart, { hours: 24 }),
|
||||
endTime: add(weekStart, { days: DAYS_SHOWN_AT_A_TIME + 1 }),
|
||||
});
|
||||
|
||||
const filters = resolveFilters(args.request, user?.preferences);
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
.container {
|
||||
--column-width: 225px;
|
||||
--columns-gap: var(--s-6);
|
||||
--columns-width: calc(
|
||||
var(--columns-count) *
|
||||
var(--column-width) +
|
||||
(var(--columns-count) - 1) *
|
||||
var(--columns-gap)
|
||||
);
|
||||
}
|
||||
|
||||
.buttonsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--s-6);
|
||||
align-items: start;
|
||||
flex-wrap: wrap-reverse;
|
||||
width: 100%;
|
||||
max-width: var(--columns-width);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.navigateButtonsContainer {
|
||||
@@ -81,8 +95,9 @@
|
||||
|
||||
.columnsContainer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns-count), 225px);
|
||||
gap: var(--s-12);
|
||||
grid-template-columns: repeat(var(--columns-count), var(--column-width));
|
||||
gap: var(--columns-gap);
|
||||
justify-content: safe center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { isToday } from "date-fns";
|
||||
import {
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
@@ -71,7 +72,11 @@ export default function CalendarPage() {
|
||||
const { previous, shown, next, current } = daysForCalendar(data.dateViewed);
|
||||
|
||||
return (
|
||||
<Main bigger className="stack lg">
|
||||
<Main
|
||||
breakoutContainer
|
||||
className={clsx("stack lg", styles.container)}
|
||||
style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME } as React.CSSProperties}
|
||||
>
|
||||
<div className={styles.buttonsContainer}>
|
||||
<div className={styles.navigateButtonsContainer}>
|
||||
<NavigateButton
|
||||
@@ -110,8 +115,9 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
key={`${shown[0].year}-${shown[0].month}-${shown[0].day}`}
|
||||
ref={scrollTodayToCenter}
|
||||
className={clsx(styles.columnsContainer, "scrollbar")}
|
||||
style={{ "--columns-count": DAYS_SHOWN_AT_A_TIME }}
|
||||
>
|
||||
{shown.map((date) => (
|
||||
<DayEventsColumn
|
||||
@@ -119,6 +125,7 @@ export default function CalendarPage() {
|
||||
date={date.day}
|
||||
month={date.month}
|
||||
year={date.year}
|
||||
isToday={isToday(new Date(date.year, date.month, date.day))}
|
||||
eventTimes={data.eventTimes.filter((event) => {
|
||||
const eventDate = new Date(event.at);
|
||||
|
||||
@@ -202,27 +209,49 @@ function CalendarDatePicker({
|
||||
className={styles.calendar}
|
||||
value={dayMonthYearToDateValue(dayMonthYear)}
|
||||
onChange={onChange}
|
||||
firstDayOfWeek="mon"
|
||||
weekSelection
|
||||
/>
|
||||
</SendouPopover>
|
||||
);
|
||||
}
|
||||
|
||||
/** Centers today's column, leaving weeks that don't contain today scrolled to their first day. */
|
||||
function scrollTodayToCenter(container: HTMLDivElement | null) {
|
||||
if (!container) return;
|
||||
|
||||
const todayColumn = container.querySelector<HTMLElement>(
|
||||
"[data-today-column]",
|
||||
);
|
||||
if (!todayColumn) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const columnRect = todayColumn.getBoundingClientRect();
|
||||
|
||||
container.scrollLeft +=
|
||||
columnRect.left -
|
||||
containerRect.left -
|
||||
(containerRect.width - columnRect.width) / 2;
|
||||
}
|
||||
|
||||
function DayEventsColumn({
|
||||
date,
|
||||
month,
|
||||
year,
|
||||
isToday,
|
||||
eventTimes,
|
||||
}: {
|
||||
date: number;
|
||||
month: number;
|
||||
year: number;
|
||||
isToday: boolean;
|
||||
eventTimes: CalendarLoaderData["eventTimes"];
|
||||
}) {
|
||||
const eventTimesCollapsed = useCollapsableEvents(eventTimes);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DayHeader date={date} month={month} year={year} />
|
||||
<div data-today-column={isToday || undefined}>
|
||||
<DayHeader date={date} month={month} year={year} isToday={isToday} />
|
||||
<div className={styles.dayEvents}>
|
||||
{eventTimesCollapsed.map((eventTime, i) => {
|
||||
return (
|
||||
@@ -246,16 +275,20 @@ function DayEventsColumn({
|
||||
);
|
||||
}
|
||||
|
||||
function DayHeader(props: { date: number; month: number; year: number }) {
|
||||
function DayHeader(props: {
|
||||
date: number;
|
||||
month: number;
|
||||
year: number;
|
||||
isToday: boolean;
|
||||
}) {
|
||||
const date = new Date(props.year, props.month, props.date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.dayHeader, {
|
||||
[styles.dayHeaderToday]: isToday,
|
||||
[styles.dayHeaderToday]: props.isToday,
|
||||
})}
|
||||
data-testid={isToday ? "today-header" : undefined}
|
||||
data-testid={props.isToday ? "today-header" : undefined}
|
||||
>
|
||||
<LocaleTime
|
||||
date={date}
|
||||
|
||||
@@ -12,12 +12,13 @@ const RANGE_COMPARISONS: [MainWeaponId, MainWeaponId][] = [
|
||||
];
|
||||
|
||||
describe("weapon range comparisons", () => {
|
||||
test.each(
|
||||
RANGE_COMPARISONS,
|
||||
)("weapon %i has more range than weapon %i", (longerId, shorterId) => {
|
||||
const [longer] = getWeaponsWithRange([longerId]);
|
||||
const [shorter] = getWeaponsWithRange([shorterId]);
|
||||
test.each(RANGE_COMPARISONS)(
|
||||
"weapon %i has more range than weapon %i",
|
||||
(longerId, shorterId) => {
|
||||
const [longer] = getWeaponsWithRange([longerId]);
|
||||
const [shorter] = getWeaponsWithRange([shorterId]);
|
||||
|
||||
expect(longer.range).toBeGreaterThan(shorter.range);
|
||||
});
|
||||
expect(longer.range).toBeGreaterThan(shorter.range);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { subMonths } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
@@ -14,12 +15,14 @@ import { LocaleTimeRange } from "~/components/LocaleTimeRange";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Config } from "~/config";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { TournamentCard } from "~/features/calendar/components/TournamentCard";
|
||||
import { PWAInstallBanner } from "~/features/front-page/components/PWAInstallBanner";
|
||||
import { SplatoonRotations } from "~/features/front-page/components/SplatoonRotations";
|
||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import styles from "~/styles/front.module.css";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
BLANK_IMAGE_URL,
|
||||
@@ -29,6 +32,7 @@ import {
|
||||
navIconUrl,
|
||||
SENDOUQ_PAGE,
|
||||
sqHeaderGuyImageUrl,
|
||||
WELCOME_PAGE,
|
||||
} from "~/utils/urls";
|
||||
import { type LeaderboardEntry, loader } from "../loaders/index.server";
|
||||
|
||||
@@ -143,6 +147,24 @@ function SeasonCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeBanner() {
|
||||
const { t } = useTranslation(["front"]);
|
||||
const user = useUser();
|
||||
|
||||
const isNewUser =
|
||||
typeof user?.createdAt === "number" &&
|
||||
databaseTimestampToDate(user.createdAt) > subMonths(new Date(), 6);
|
||||
|
||||
if (user && !isNewUser) return null;
|
||||
|
||||
return (
|
||||
<Link to={WELCOME_PAGE} className={styles.welcomeBanner}>
|
||||
{t("front:welcomeBanner")}
|
||||
<ArrowRightIcon />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function LeagueBanner() {
|
||||
const showBannerFor = Config.showBannerForSeason;
|
||||
if (!showBannerFor) return null;
|
||||
@@ -340,6 +362,7 @@ function DiscoverFeatures() {
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<WelcomeBanner />
|
||||
<PWAInstallBanner />
|
||||
</div>
|
||||
);
|
||||
|
||||
58
app/features/info/routes/welcome.module.css
Normal file
58
app/features/info/routes/welcome.module.css
Normal file
@@ -0,0 +1,58 @@
|
||||
.heroFigure {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s-1);
|
||||
|
||||
& figcaption {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.heroContainer {
|
||||
position: relative;
|
||||
border-radius: var(--radius-box);
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-second);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.heroImg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 2 / 1;
|
||||
object-fit: cover;
|
||||
object-position: center 50%;
|
||||
transform: scale(1.2);
|
||||
transform-origin: 80% 60%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.pagePill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-full);
|
||||
padding: var(--s-0-5) var(--s-2);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semi);
|
||||
vertical-align: middle;
|
||||
transition: background-color 0.15s ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
}
|
||||
185
app/features/info/routes/welcome.tsx
Normal file
185
app/features/info/routes/welcome.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import { Main } from "~/components/Main";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
ANALYZER_URL,
|
||||
BUILDS_PAGE,
|
||||
CALENDAR_PAGE,
|
||||
FAQ_PAGE,
|
||||
LFG_PAGE,
|
||||
LUTI_PAGE,
|
||||
MATCH_PROFILE_PAGE,
|
||||
navIconUrl,
|
||||
OBJECT_DAMAGE_CALCULATOR_URL,
|
||||
SENDOUQ_PAGE,
|
||||
scrimsPage,
|
||||
TIERS_PAGE,
|
||||
VODS_PAGE,
|
||||
WELCOME_HERO_IMAGE_PATH,
|
||||
} from "~/utils/urls";
|
||||
import styles from "./welcome.module.css";
|
||||
|
||||
const SECTION_KEYS = [
|
||||
"whatDoINeed",
|
||||
"motionControls",
|
||||
"weapons",
|
||||
"builds",
|
||||
"mapsModes",
|
||||
"findingTeam",
|
||||
"noTeam",
|
||||
"haveTeam",
|
||||
"ranks",
|
||||
"divs",
|
||||
"plusServer",
|
||||
] as const;
|
||||
|
||||
const PAGE_PILLS: Record<
|
||||
string,
|
||||
{ to: string; labelKey: string; navItem?: string }
|
||||
> = {
|
||||
builds: {
|
||||
to: BUILDS_PAGE,
|
||||
labelKey: "common:pages.builds",
|
||||
navItem: "builds",
|
||||
},
|
||||
analyzer: {
|
||||
to: ANALYZER_URL,
|
||||
labelKey: "common:pages.analyzer",
|
||||
navItem: "analyzer",
|
||||
},
|
||||
"object-damage-calculator": {
|
||||
to: OBJECT_DAMAGE_CALCULATOR_URL,
|
||||
labelKey: "common:pages.object-damage-calculator",
|
||||
navItem: "object-damage-calculator",
|
||||
},
|
||||
lfg: {
|
||||
to: LFG_PAGE,
|
||||
labelKey: "common:pages.lfg",
|
||||
navItem: "lfg",
|
||||
},
|
||||
settings: {
|
||||
to: MATCH_PROFILE_PAGE,
|
||||
labelKey: "common:pages.settings",
|
||||
navItem: "settings",
|
||||
},
|
||||
calendar: {
|
||||
to: CALENDAR_PAGE,
|
||||
labelKey: "common:pages.calendar",
|
||||
navItem: "calendar",
|
||||
},
|
||||
vods: {
|
||||
to: VODS_PAGE,
|
||||
labelKey: "common:pages.vods",
|
||||
navItem: "vods",
|
||||
},
|
||||
sendouq: {
|
||||
to: SENDOUQ_PAGE,
|
||||
labelKey: "common:pages.sendouq",
|
||||
navItem: "sendouq",
|
||||
},
|
||||
scrims: {
|
||||
to: scrimsPage(),
|
||||
labelKey: "common:pages.scrims",
|
||||
navItem: "scrims",
|
||||
},
|
||||
tiers: {
|
||||
to: TIERS_PAGE,
|
||||
labelKey: "welcome:pills.tiers",
|
||||
navItem: "sendouq",
|
||||
},
|
||||
luti: {
|
||||
to: LUTI_PAGE,
|
||||
labelKey: "common:pages.luti",
|
||||
navItem: "luti",
|
||||
},
|
||||
faq: {
|
||||
to: FAQ_PAGE,
|
||||
labelKey: "common:pages.faq",
|
||||
},
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
return metaTags({
|
||||
title: "Welcome",
|
||||
description: "Guide to competitive Splatoon for new players",
|
||||
location: args.location,
|
||||
});
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "welcome",
|
||||
};
|
||||
|
||||
export default function WelcomePage() {
|
||||
const { t } = useTranslation(["welcome"]);
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
<figure className={styles.heroFigure}>
|
||||
<div className={styles.heroContainer}>
|
||||
<img
|
||||
src={WELCOME_HERO_IMAGE_PATH}
|
||||
alt=""
|
||||
className={styles.heroImg}
|
||||
width={3200}
|
||||
height={4000}
|
||||
/>
|
||||
</div>
|
||||
<figcaption className="text-xs text-lighter">
|
||||
{t("welcome:photoCredit")}
|
||||
</figcaption>
|
||||
</figure>
|
||||
<h1 className="text-xl">{t("welcome:title")}</h1>
|
||||
{SECTION_KEYS.map((sectionKey) => (
|
||||
<section key={sectionKey} className="stack sm">
|
||||
<h2 className="text-lg">
|
||||
{t(`welcome:${sectionKey}.header` as any)}
|
||||
</h2>
|
||||
{(t(`welcome:${sectionKey}.body` as any) as string)
|
||||
.split("\n\n")
|
||||
.map((paragraph) => (
|
||||
<ParagraphWithPagePills key={paragraph} text={paragraph} />
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function ParagraphWithPagePills({ text }: { text: string }) {
|
||||
const parts = text.split(/>>([a-z-]+)<</);
|
||||
|
||||
return (
|
||||
<p className={styles.paragraph}>
|
||||
{parts.map((part, i) => {
|
||||
if (i % 2 === 0) return part;
|
||||
|
||||
return <PagePill key={part} slug={part} />;
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function PagePill({ slug }: { slug: string }) {
|
||||
const { t } = useTranslation(["welcome", "common"]);
|
||||
const pill = PAGE_PILLS[slug];
|
||||
|
||||
if (!pill) return <>{slug}</>;
|
||||
|
||||
return (
|
||||
<Link to={pill.to} className={styles.pagePill}>
|
||||
{pill.navItem ? (
|
||||
<img
|
||||
src={`${navIconUrl(pill.navItem)}.avif`}
|
||||
width={16}
|
||||
height={16}
|
||||
alt=""
|
||||
/>
|
||||
) : null}
|
||||
{t(pill.labelKey as any)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import type { UserMapModePreferences } from "~/db/tables";
|
||||
import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test";
|
||||
import * as MatchProfileRepository from "./MatchProfileRepository.server";
|
||||
|
||||
const USER_ID = 1;
|
||||
|
||||
const PREFERENCES: UserMapModePreferences = {
|
||||
modes: [{ mode: "SZ", preference: "PREFER" }],
|
||||
pool: [{ mode: "SZ", stages: [1, 2, 3, 4] }],
|
||||
};
|
||||
|
||||
const OTHER_PREFERENCES: UserMapModePreferences = {
|
||||
modes: [{ mode: "SZ", preference: "PREFER" }],
|
||||
pool: [{ mode: "SZ", stages: [5, 6, 7, 8] }],
|
||||
};
|
||||
|
||||
const updateProfile = (
|
||||
args: Partial<
|
||||
Parameters<typeof MatchProfileRepository.updateOwnMatchProfile>[0]
|
||||
> = {},
|
||||
) =>
|
||||
withUserId(USER_ID, () =>
|
||||
MatchProfileRepository.updateOwnMatchProfile({
|
||||
mapModePreferences: PREFERENCES,
|
||||
vc: "NO",
|
||||
languages: [],
|
||||
weaponPool: [],
|
||||
noScreen: 0,
|
||||
...args,
|
||||
}),
|
||||
);
|
||||
|
||||
describe("updateOwnMatchProfile", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(1);
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({ mapModePreferences: JSON.stringify(PREFERENCES), noScreen: 0 })
|
||||
.where("id", "=", USER_ID)
|
||||
.execute();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("reports no change when nothing matchmaking-relevant changed", async () => {
|
||||
const result = await updateProfile({ vc: "YES", languages: ["en"] });
|
||||
|
||||
expect(result.mapModePreferencesChanged).toBe(false);
|
||||
expect(result.noScreenChanged).toBe(false);
|
||||
});
|
||||
|
||||
test("detects a noScreen change", async () => {
|
||||
const result = await updateProfile({ noScreen: 1 });
|
||||
|
||||
expect(result.noScreenChanged).toBe(true);
|
||||
expect(result.mapModePreferencesChanged).toBe(false);
|
||||
});
|
||||
|
||||
test("detects a map/mode preferences change", async () => {
|
||||
const result = await updateProfile({
|
||||
mapModePreferences: OTHER_PREFERENCES,
|
||||
});
|
||||
|
||||
expect(result.mapModePreferencesChanged).toBe(true);
|
||||
expect(result.noScreenChanged).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
@@ -56,20 +57,29 @@ export async function updateOwnMatchProfile({
|
||||
noScreen: number;
|
||||
}) {
|
||||
const userId = actorId();
|
||||
const currentPreferences = (
|
||||
await db
|
||||
.selectFrom("User")
|
||||
.select("mapModePreferences")
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow()
|
||||
).mapModePreferences;
|
||||
const current = await db
|
||||
.selectFrom("User")
|
||||
.select(["mapModePreferences", "noScreen"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const mergedPool = mergeExcludedModePreferences(
|
||||
mapModePreferences.pool,
|
||||
currentPreferences?.pool,
|
||||
current.mapModePreferences?.pool,
|
||||
);
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const newMapModePreferences: UserMapModePreferences = {
|
||||
...mapModePreferences,
|
||||
pool: mergedPool,
|
||||
};
|
||||
|
||||
const mapModePreferencesChanged = !R.isDeepEqual(
|
||||
newMapModePreferences,
|
||||
current.mapModePreferences,
|
||||
);
|
||||
const noScreenChanged = current.noScreen !== noScreen;
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx
|
||||
.deleteFrom("UserWeaponPool")
|
||||
.where("userId", "=", userId)
|
||||
@@ -92,10 +102,7 @@ export async function updateOwnMatchProfile({
|
||||
await trx
|
||||
.updateTable("User")
|
||||
.set({
|
||||
mapModePreferences: JSON.stringify({
|
||||
...mapModePreferences,
|
||||
pool: mergedPool,
|
||||
}),
|
||||
mapModePreferences: JSON.stringify(newMapModePreferences),
|
||||
vc,
|
||||
languages: languages.length > 0 ? languages.join(",") : null,
|
||||
noScreen,
|
||||
@@ -103,6 +110,8 @@ export async function updateOwnMatchProfile({
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
});
|
||||
|
||||
return { mapModePreferencesChanged, noScreenChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { add, sub } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TeamRepository from "~/features/team/TeamRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { DuplicateEntryError } from "~/utils/errors";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import * as ScrimPostRepository from "./ScrimPostRepository.server";
|
||||
|
||||
@@ -284,3 +286,85 @@ describe("findUserScrims", () => {
|
||||
expect(postOwnerScrims[0]!.status).toBe("booked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertRequest", () => {
|
||||
beforeEach(async () => {
|
||||
await dbInsertUsers(5);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbReset();
|
||||
});
|
||||
|
||||
const insertTeamRequest = ({
|
||||
scrimPostId,
|
||||
teamId,
|
||||
userId,
|
||||
}: {
|
||||
scrimPostId: number;
|
||||
teamId: number;
|
||||
userId: number;
|
||||
}) =>
|
||||
ScrimPostRepository.insertRequest({
|
||||
scrimPostId,
|
||||
teamId,
|
||||
message: null,
|
||||
at: null,
|
||||
users: [{ userId, isOwner: 1 }],
|
||||
});
|
||||
|
||||
test("throws if the team already has a request for the post", async () => {
|
||||
const postId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
const team = await TeamRepository.create({
|
||||
name: "Team Olive",
|
||||
ownerUserId: 2,
|
||||
isMainTeam: true,
|
||||
});
|
||||
|
||||
await insertTeamRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: team.id,
|
||||
userId: 2,
|
||||
});
|
||||
|
||||
await expect(
|
||||
insertTeamRequest({ scrimPostId: postId, teamId: team.id, userId: 3 }),
|
||||
).rejects.toThrowError(DuplicateEntryError);
|
||||
|
||||
const post = await ScrimPostRepository.findById(postId);
|
||||
expect(post!.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("allows the team to request another post", async () => {
|
||||
const postId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 1, isOwner: 1 }],
|
||||
});
|
||||
const otherPostId = await insertPost({
|
||||
at: BOOKED_AT,
|
||||
users: [{ userId: 4, isOwner: 1 }],
|
||||
});
|
||||
const team = await TeamRepository.create({
|
||||
name: "Team Olive",
|
||||
ownerUserId: 2,
|
||||
isMainTeam: true,
|
||||
});
|
||||
|
||||
await insertTeamRequest({
|
||||
scrimPostId: postId,
|
||||
teamId: team.id,
|
||||
userId: 2,
|
||||
});
|
||||
await insertTeamRequest({
|
||||
scrimPostId: otherPostId,
|
||||
teamId: team.id,
|
||||
userId: 2,
|
||||
});
|
||||
|
||||
const otherPost = await ScrimPostRepository.findById(otherPostId);
|
||||
expect(otherPost!.requests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,10 @@ import { jsonArrayFrom, jsonBuildObject } from "kysely/helpers/sqlite";
|
||||
import type { Tables, TablesInsertable } from "~/db/tables";
|
||||
import { actorId, actorIdOrNull } from "~/features/auth/core/user.server";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import {
|
||||
ConcurrentModificationError,
|
||||
DuplicateEntryError,
|
||||
} from "~/utils/errors";
|
||||
import { shortNanoid } from "~/utils/id";
|
||||
import {
|
||||
commonUserSelect,
|
||||
@@ -80,10 +83,30 @@ type InsertRequestArgs = Pick<
|
||||
>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inserts a new request to a scrim post.
|
||||
*
|
||||
* @throws {DuplicateEntryError} If the team already has a request for the post
|
||||
*/
|
||||
export function insertRequest(args: InsertRequestArgs) {
|
||||
invariant(args.users.length > 0, "At least one user must be provided");
|
||||
|
||||
return db.transaction().execute(async (trx) => {
|
||||
if (typeof args.teamId === "number") {
|
||||
const existingTeamRequest = await trx
|
||||
.selectFrom("ScrimPostRequest")
|
||||
.select("id")
|
||||
.where("scrimPostId", "=", args.scrimPostId)
|
||||
.where("teamId", "=", args.teamId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (existingTeamRequest) {
|
||||
throw new DuplicateEntryError(
|
||||
"Team already has a request for this scrim post",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const newRequest = await trx
|
||||
.insertInto("ScrimPostRequest")
|
||||
.values({
|
||||
|
||||
@@ -13,7 +13,10 @@ import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
} from "~/utils/dates";
|
||||
import { ConcurrentModificationError } from "~/utils/errors";
|
||||
import {
|
||||
ConcurrentModificationError,
|
||||
DuplicateEntryError,
|
||||
} from "~/utils/errors";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
actionError,
|
||||
@@ -92,18 +95,25 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: data.scrimPostId,
|
||||
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
|
||||
message: data.message,
|
||||
at: data.at ? dateToDatabaseTimestamp(data.at) : null,
|
||||
users: (
|
||||
await usersListForPost({ authorId: user.id, from: data.from })
|
||||
).map((userId) => ({
|
||||
userId,
|
||||
isOwner: Number(user.id === userId),
|
||||
})),
|
||||
});
|
||||
try {
|
||||
await ScrimPostRepository.insertRequest({
|
||||
scrimPostId: data.scrimPostId,
|
||||
teamId: data.from.mode === "TEAM" ? data.from.teamId : null,
|
||||
message: data.message,
|
||||
at: data.at ? dateToDatabaseTimestamp(data.at) : null,
|
||||
users: (
|
||||
await usersListForPost({ authorId: user.id, from: data.from })
|
||||
).map((userId) => ({
|
||||
userId,
|
||||
isOwner: Number(user.id === userId),
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DuplicateEntryError) {
|
||||
errorToast("Your team has already requested this scrim");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
notify({
|
||||
userIds: post.users
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function findById(id: number) {
|
||||
"GroupMatch.memento",
|
||||
"GroupMatch.cancelRequestedByUserId",
|
||||
"GroupMatch.cancelAcceptedByUserId",
|
||||
"GroupMatch.noScreen",
|
||||
|
||||
exists(
|
||||
selectFrom("Skill")
|
||||
@@ -476,6 +477,14 @@ export function create({
|
||||
throw new SendouQError("Can't leave group when already in a match");
|
||||
}
|
||||
|
||||
const memberPreferringNoScreen = await trx
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select("User.id")
|
||||
.where("GroupMember.groupId", "in", [alphaGroupId, bravoGroupId])
|
||||
.where("User.noScreen", "=", 1)
|
||||
.executeTakeFirst();
|
||||
|
||||
const match = await trx
|
||||
.insertInto("GroupMatch")
|
||||
.values({
|
||||
@@ -483,6 +492,7 @@ export function create({
|
||||
bravoGroupId,
|
||||
chatCode: shortNanoid(),
|
||||
memento: JSON.stringify(memento),
|
||||
noScreen: memberPreferringNoScreen ? 1 : 0,
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
@@ -109,9 +109,7 @@ export function SendouQMatchBanner({ data }: { data: SendouQMatchLoaderData }) {
|
||||
<MatchBanner
|
||||
stageId={currentMap.stageId}
|
||||
mode={currentMap.mode}
|
||||
screenLegal={
|
||||
!data.match.groupAlpha.noScreen && !data.match.groupBravo.noScreen
|
||||
}
|
||||
screenLegal={!data.match.noScreen}
|
||||
joinPool={joinPool}
|
||||
joinPass={joinPass}
|
||||
>
|
||||
|
||||
@@ -675,6 +675,11 @@ export function deleteLike({
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes every like where the given group is the liker or the target. */
|
||||
export function deleteAllLikesByGroupId(groupId: number) {
|
||||
return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx));
|
||||
}
|
||||
|
||||
export function leaveGroup(userId: number) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const userGroup = await trx
|
||||
|
||||
@@ -183,7 +183,6 @@ class SendouQClass {
|
||||
return {
|
||||
...group,
|
||||
chatCode: isTeamMember ? group.chatCode : undefined,
|
||||
noScreen: this.#groupNoScreen(group),
|
||||
tier: match.memento?.groups[group.id]?.tier,
|
||||
skillDifference: match.memento?.groups[group.id]?.skillDifference,
|
||||
matchmade: Boolean(group.matchmade),
|
||||
@@ -234,6 +233,7 @@ class SendouQClass {
|
||||
return {
|
||||
...match,
|
||||
chatCode: isMatchInsider ? match.chatCode : undefined,
|
||||
noScreen: Boolean(match.noScreen),
|
||||
currentMap,
|
||||
groupAlpha: alphaCensored,
|
||||
groupBravo: bravoCensored,
|
||||
|
||||
44
app/features/sendouq/core/likes.server.ts
Normal file
44
app/features/sendouq/core/likes.server.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as R from "remeda";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
|
||||
import {
|
||||
FULL_GROUP_SIZE,
|
||||
SENDOUQ_LOOKING_ROOM,
|
||||
sqGroupWebsocketRoom,
|
||||
} from "../q-constants";
|
||||
import { refreshSendouQInstance, SendouQ } from "./SendouQ.server";
|
||||
|
||||
/**
|
||||
* Cancels every pending challenge (both given and received) involving the user's
|
||||
* active and full SendouQ group. Only full groups are affected: partial groups
|
||||
* merge (rather than start a match) when a request is accepted, so their members'
|
||||
* preferences are not yet locked in.
|
||||
*/
|
||||
export async function cancelActiveGroupLikes(userId: number) {
|
||||
const ownGroup = SendouQ.findOwnGroup(userId);
|
||||
if (!ownGroup) return;
|
||||
if (ownGroup.status !== "ACTIVE" || ownGroup.matchId) return;
|
||||
if (ownGroup.members.length !== FULL_GROUP_SIZE) return;
|
||||
|
||||
const likes = await SQGroupRepository.allLikesByGroupId(ownGroup.id);
|
||||
const affectedGroupIds = R.unique([
|
||||
...likes.given.map((like) => like.groupId),
|
||||
...likes.received.map((like) => like.groupId),
|
||||
]);
|
||||
if (affectedGroupIds.length === 0) return;
|
||||
|
||||
await SQGroupRepository.deleteAllLikesByGroupId(ownGroup.id);
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
ChatSystemMessage.send([
|
||||
...[...affectedGroupIds, ownGroup.id].map((groupId) => ({
|
||||
room: sqGroupWebsocketRoom(groupId),
|
||||
revalidateOnly: true,
|
||||
})),
|
||||
{
|
||||
room: SENDOUQ_LOOKING_ROOM,
|
||||
revalidateOnly: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
|
||||
import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
@@ -59,13 +60,20 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
break;
|
||||
}
|
||||
case "UPDATE_MATCH_PROFILE": {
|
||||
await MatchProfileRepository.updateOwnMatchProfile({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
vc: data.vc,
|
||||
languages: data.languages,
|
||||
weaponPool: data.weaponPool,
|
||||
noScreen: Number(data.noScreen),
|
||||
});
|
||||
const { mapModePreferencesChanged, noScreenChanged } =
|
||||
await MatchProfileRepository.updateOwnMatchProfile({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
vc: data.vc,
|
||||
languages: data.languages,
|
||||
weaponPool: data.weaponPool,
|
||||
noScreen: Number(data.noScreen),
|
||||
});
|
||||
|
||||
// Challenges are made based on the modes/preferences shown at that
|
||||
// moment, so changing them must undo pending requests to/from the group.
|
||||
if (mapModePreferencesChanged || noScreenChanged) {
|
||||
await cancelActiveGroupLikes(user.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -744,6 +744,70 @@ describe("double elimination standings - projected ties", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("single elimination source - underground", () => {
|
||||
// 8-team SE played out fully. The four first-round losers tie for last, so
|
||||
// sourcing [-1] should feed exactly those teams into an underground bracket.
|
||||
const playedSingleEliminationTournament = () => {
|
||||
let data = createResolved({
|
||||
type: "single_elimination",
|
||||
seeding: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
settings: {},
|
||||
});
|
||||
|
||||
const winnersGroupId = data.group.find((group) => group.number === 1)!.id;
|
||||
const firstRoundId = data.round.find(
|
||||
(round) => round.groupId === winnersGroupId && round.number === 1,
|
||||
)!.id;
|
||||
|
||||
// lower id wins, so the higher id in each first-round match is the loser
|
||||
const firstRoundLoserIds = readyMatches(
|
||||
data,
|
||||
(match) => match.roundId === firstRoundId,
|
||||
).map((match) => Math.max(match.opponent1!.id!, match.opponent2!.id!));
|
||||
|
||||
let ready = readyMatches(data, (match) => match.groupId === winnersGroupId);
|
||||
while (ready.length) {
|
||||
for (const match of ready) {
|
||||
data = reportLowerIdWinner(data, match.id);
|
||||
}
|
||||
ready = readyMatches(data, (match) => match.groupId === winnersGroupId);
|
||||
}
|
||||
|
||||
const tournament = testTournament({
|
||||
ctx: {
|
||||
settings: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "SE",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
data,
|
||||
});
|
||||
|
||||
return { tournament, firstRoundLoserIds };
|
||||
};
|
||||
|
||||
it("sources the first-round losers when placements are [-1]", () => {
|
||||
const { tournament, firstRoundLoserIds } =
|
||||
playedSingleEliminationTournament();
|
||||
|
||||
const { teams, relevantMatchesFinished } = tournament
|
||||
.bracketByIdx(0)!
|
||||
.source({ placements: [-1] });
|
||||
|
||||
expect(relevantMatchesFinished).toBe(true);
|
||||
expect([...teams].sort((a, b) => a - b)).toEqual(
|
||||
[...firstRoundLoserIds].sort((a, b) => a - b),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function reportLowerIdWinner(data: BracketData, matchId: number): BracketData {
|
||||
const match = matchById(data, matchId);
|
||||
const opponent1Lower = match.opponent1!.id! < match.opponent2!.id!;
|
||||
|
||||
@@ -14,11 +14,6 @@ export class SingleEliminationBracket extends Bracket {
|
||||
return "single_elimination";
|
||||
}
|
||||
|
||||
/** Unreachable: bracket progression validation rejects single elimination as a source. */
|
||||
source(): never {
|
||||
throw new Error("Single elimination bracket can't be a source");
|
||||
}
|
||||
|
||||
defaultRoundBestOfs(data: BracketData) {
|
||||
const result: BracketMapCounts = new Map();
|
||||
|
||||
@@ -165,4 +160,58 @@ export class SingleEliminationBracket extends Bracket {
|
||||
|
||||
return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken);
|
||||
}
|
||||
|
||||
source({ placements }: { placements: number[] }) {
|
||||
invariant(placements.length > 0, "Empty placements not supported");
|
||||
invariant(
|
||||
placements.every((placement) => placement < 0),
|
||||
"Positive placements in SE not implemented",
|
||||
);
|
||||
|
||||
// third place match lives in a separate (higher) group; the winners
|
||||
// group teams get eliminated from is the lowest group id
|
||||
const mainGroupId = Math.min(...this.data.group.map((group) => group.id));
|
||||
|
||||
const orderedRoundsIds = this.data.round
|
||||
.filter((round) => round.groupId === mainGroupId)
|
||||
.map((round) => round.id)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
const amountOfRounds = Math.abs(Math.min(...placements));
|
||||
|
||||
const sourceRoundsIds = orderedRoundsIds.slice(0, amountOfRounds).sort(
|
||||
// teams who made it further in the bracket get higher seed
|
||||
(a, b) => b - a,
|
||||
);
|
||||
|
||||
const teams: number[] = [];
|
||||
let relevantMatchesFinished = true;
|
||||
for (const roundId of sourceRoundsIds) {
|
||||
const roundsMatches = this.data.match.filter(
|
||||
(match) => match.roundId === roundId,
|
||||
);
|
||||
|
||||
for (const match of roundsMatches) {
|
||||
// BYE
|
||||
if (!match.opponent1 || !match.opponent2) {
|
||||
continue;
|
||||
}
|
||||
if (!match.winnerSide) {
|
||||
relevantMatchesFinished = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const loser =
|
||||
match.winnerSide === "opponent1" ? match.opponent2 : match.opponent1;
|
||||
invariant(loser?.id, "Loser id not found");
|
||||
|
||||
teams.push(loser.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
relevantMatchesFinished,
|
||||
teams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,7 +917,7 @@ describe("validatedSources - other rules", () => {
|
||||
expect((error as any).bracketIdx).toEqual(1);
|
||||
});
|
||||
|
||||
it("handles NO_SE_SOURCE", () => {
|
||||
it("handles NO_SE_POSITIVE", () => {
|
||||
const error = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
@@ -935,10 +935,31 @@ describe("validatedSources - other rules", () => {
|
||||
},
|
||||
]) as Progression.ValidationError;
|
||||
|
||||
expect(error.type).toBe("NO_SE_SOURCE");
|
||||
expect(error.type).toBe("NO_SE_POSITIVE");
|
||||
expect((error as any).bracketIdx).toEqual(1);
|
||||
});
|
||||
|
||||
it("allows single elimination to source an underground bracket", () => {
|
||||
const result = getValidatedBrackets([
|
||||
{
|
||||
settings: {},
|
||||
type: "single_elimination",
|
||||
},
|
||||
{
|
||||
settings: {},
|
||||
type: "single_elimination",
|
||||
sources: [
|
||||
{
|
||||
bracketId: "0",
|
||||
placements: "-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(Progression.isBrackets(result)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles NO_DE_POSITIVE", () => {
|
||||
const error = getValidatedBrackets([
|
||||
{
|
||||
@@ -1278,6 +1299,21 @@ describe("isUnderground", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("handles SE w/ underground bracket", () => {
|
||||
expect(
|
||||
Progression.isUnderground(
|
||||
0,
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Progression.isUnderground(
|
||||
1,
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("throws if given idx is out of bounds", () => {
|
||||
expect(() =>
|
||||
Progression.isUnderground(1, progressions.singleElimination),
|
||||
@@ -1348,6 +1384,14 @@ describe("bracketIdxsForStandings", () => {
|
||||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when DE is the source
|
||||
});
|
||||
|
||||
it("handles SE w/ underground bracket", () => {
|
||||
expect(
|
||||
Progression.bracketIdxsForStandings(
|
||||
progressions.singleEliminationWithUnderground,
|
||||
),
|
||||
).toEqual([0]); // missing 1 because it's underground when SE is the source
|
||||
});
|
||||
});
|
||||
|
||||
describe("startingBrackets", () => {
|
||||
|
||||
@@ -90,9 +90,9 @@ export type ValidationError =
|
||||
type: "NEGATIVE_PROGRESSION";
|
||||
bracketIdx: number;
|
||||
}
|
||||
// single elimination is not a valid source bracket (might change in the future)
|
||||
// no SE positive placements (single elimination can only source underground brackets)
|
||||
| {
|
||||
type: "NO_SE_SOURCE";
|
||||
type: "NO_SE_POSITIVE";
|
||||
bracketIdx: number;
|
||||
}
|
||||
// no DE positive placements (might change in the future)
|
||||
@@ -287,10 +287,10 @@ export function bracketsToValidationError(
|
||||
};
|
||||
}
|
||||
|
||||
faultyBracketIdx = noSingleEliminationAsSource(brackets);
|
||||
faultyBracketIdx = noSingleEliminationPositive(brackets);
|
||||
if (typeof faultyBracketIdx === "number") {
|
||||
return {
|
||||
type: "NO_SE_SOURCE",
|
||||
type: "NO_SE_POSITIVE",
|
||||
bracketIdx: faultyBracketIdx,
|
||||
};
|
||||
}
|
||||
@@ -671,11 +671,14 @@ function negativeProgression(brackets: ParsedBracket[]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function noSingleEliminationAsSource(brackets: ParsedBracket[]) {
|
||||
function noSingleEliminationPositive(brackets: ParsedBracket[]) {
|
||||
for (const [bracketIdx, bracket] of brackets.entries()) {
|
||||
for (const source of bracket.sources ?? []) {
|
||||
const sourceBracket = brackets[source.bracketIdx];
|
||||
if (sourceBracket.type === "single_elimination") {
|
||||
if (
|
||||
sourceBracket.type === "single_elimination" &&
|
||||
source.placements.some((placement) => placement > 0)
|
||||
) {
|
||||
return bracketIdx;
|
||||
}
|
||||
}
|
||||
@@ -944,7 +947,8 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) {
|
||||
|
||||
return !sources.some(
|
||||
(source) =>
|
||||
progression[source.bracketIdx].type === "double_elimination",
|
||||
progression[source.bracketIdx].type === "double_elimination" ||
|
||||
progression[source.bracketIdx].type === "single_elimination",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -173,89 +173,97 @@ describe("Swiss", () => {
|
||||
const PAIR_UP_TEST_CASES = [RUSH_WEEKEND_3, LOW_INK_AUGUST_2025];
|
||||
|
||||
describe("pairUp()", () => {
|
||||
it.for(
|
||||
PAIR_UP_TEST_CASES,
|
||||
)("all teams have matches (pair up test cases idx %#)", (testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
it.for(PAIR_UP_TEST_CASES)(
|
||||
"all teams have matches (pair up test cases idx %#)",
|
||||
(testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
|
||||
const inputTeams = testCase.map((team) => team.id).sort((a, b) => a - b);
|
||||
const resultTeams = result
|
||||
.flatMap((match) => [match.opponentOne, match.opponentTwo])
|
||||
.filter((val) => val !== null)
|
||||
.sort((a, b) => a - b);
|
||||
const inputTeams = testCase
|
||||
.map((team) => team.id)
|
||||
.sort((a, b) => a - b);
|
||||
const resultTeams = result
|
||||
.flatMap((match) => [match.opponentOne, match.opponentTwo])
|
||||
.filter((val) => val !== null)
|
||||
.sort((a, b) => a - b);
|
||||
|
||||
expect(inputTeams).toEqual(resultTeams);
|
||||
});
|
||||
expect(inputTeams).toEqual(resultTeams);
|
||||
},
|
||||
);
|
||||
|
||||
it.for(
|
||||
PAIR_UP_TEST_CASES,
|
||||
)("every pair is max one set win from each other (pair up test cases idx %#)", (testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
it.for(PAIR_UP_TEST_CASES)(
|
||||
"every pair is max one set win from each other (pair up test cases idx %#)",
|
||||
(testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null) continue;
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null)
|
||||
continue;
|
||||
|
||||
const opponentOneScore = testCase.find(
|
||||
(t) => t.id === match.opponentOne,
|
||||
)!.score;
|
||||
const opponentTwoScore = testCase.find(
|
||||
(t) => t.id === match.opponentTwo,
|
||||
)!.score;
|
||||
const opponentOneScore = testCase.find(
|
||||
(t) => t.id === match.opponentOne,
|
||||
)!.score;
|
||||
const opponentTwoScore = testCase.find(
|
||||
(t) => t.id === match.opponentTwo,
|
||||
)!.score;
|
||||
|
||||
expect(
|
||||
Math.abs(opponentOneScore - opponentTwoScore),
|
||||
`Teams ${match.opponentOne} and ${match.opponentTwo} have too large score difference (${opponentOneScore} vs ${opponentTwoScore})`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it.for(
|
||||
PAIR_UP_TEST_CASES,
|
||||
)("should match perfect records against each other as much as possible (pair up test cases idx %#)", (testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
|
||||
const maxScore = testCase.reduce(
|
||||
(max, team) => Math.max(max, team.score),
|
||||
0,
|
||||
);
|
||||
const perfectRecordsCount = testCase.filter(
|
||||
(team) => team.score === maxScore,
|
||||
).length;
|
||||
|
||||
let perfectRecordsPlayingEachOtherCount = 0;
|
||||
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null) continue;
|
||||
|
||||
const oneIsPerfectScore = testCase.some(
|
||||
(team) => team.id === match.opponentOne && team.score === maxScore,
|
||||
);
|
||||
const twoIsPerfectScore = testCase.some(
|
||||
(team) => team.id === match.opponentTwo && team.score === maxScore,
|
||||
);
|
||||
|
||||
if (oneIsPerfectScore && twoIsPerfectScore) {
|
||||
perfectRecordsPlayingEachOtherCount++;
|
||||
expect(
|
||||
Math.abs(opponentOneScore - opponentTwoScore),
|
||||
`Teams ${match.opponentOne} and ${match.opponentTwo} have too large score difference (${opponentOneScore} vs ${opponentTwoScore})`,
|
||||
).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
expect(perfectRecordsPlayingEachOtherCount).toBe(
|
||||
Math.floor(perfectRecordsCount / 2),
|
||||
);
|
||||
});
|
||||
it.for(PAIR_UP_TEST_CASES)(
|
||||
"should match perfect records against each other as much as possible (pair up test cases idx %#)",
|
||||
(testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
|
||||
it.for(
|
||||
PAIR_UP_TEST_CASES,
|
||||
)("generates max one bye (pair up test cases idx %#)", (testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
const maxScore = testCase.reduce(
|
||||
(max, team) => Math.max(max, team.score),
|
||||
0,
|
||||
);
|
||||
const perfectRecordsCount = testCase.filter(
|
||||
(team) => team.score === maxScore,
|
||||
).length;
|
||||
|
||||
let byes = 0;
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null) byes++;
|
||||
}
|
||||
let perfectRecordsPlayingEachOtherCount = 0;
|
||||
|
||||
expect(byes).toBeLessThanOrEqual(1);
|
||||
});
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null)
|
||||
continue;
|
||||
|
||||
const oneIsPerfectScore = testCase.some(
|
||||
(team) => team.id === match.opponentOne && team.score === maxScore,
|
||||
);
|
||||
const twoIsPerfectScore = testCase.some(
|
||||
(team) => team.id === match.opponentTwo && team.score === maxScore,
|
||||
);
|
||||
|
||||
if (oneIsPerfectScore && twoIsPerfectScore) {
|
||||
perfectRecordsPlayingEachOtherCount++;
|
||||
}
|
||||
}
|
||||
|
||||
expect(perfectRecordsPlayingEachOtherCount).toBe(
|
||||
Math.floor(perfectRecordsCount / 2),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.for(PAIR_UP_TEST_CASES)(
|
||||
"generates max one bye (pair up test cases idx %#)",
|
||||
(testCase) => {
|
||||
const result = Swiss.pairUp(testCase);
|
||||
|
||||
let byes = 0;
|
||||
for (const match of result) {
|
||||
if (match.opponentOne === null || match.opponentTwo === null) byes++;
|
||||
}
|
||||
|
||||
expect(byes).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("calculateTeamStatus()", () => {
|
||||
|
||||
@@ -319,4 +319,21 @@ export const progressions = {
|
||||
],
|
||||
},
|
||||
],
|
||||
singleEliminationWithUnderground: [
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
},
|
||||
{
|
||||
...DEFAULT_PROGRESSION_ARGS,
|
||||
type: "single_elimination",
|
||||
name: "Underground",
|
||||
sources: [
|
||||
{
|
||||
bracketIdx: 0,
|
||||
placements: [-1],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies Record<string, Progression.ParsedBracket[]>;
|
||||
|
||||
@@ -180,6 +180,15 @@ export default function TournamentBracketsPage() {
|
||||
)} rounds of the losers bracket can play in this bracket`;
|
||||
}
|
||||
|
||||
if (
|
||||
tournament.brackets[0].type === "single_elimination" &&
|
||||
bracket.isUnderground
|
||||
) {
|
||||
return `Teams that get eliminated in the first ${Math.abs(
|
||||
Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
|
||||
)} rounds can play in this bracket`;
|
||||
}
|
||||
|
||||
const advanceThreshold = tournament.brackets[0].settings?.advanceThreshold;
|
||||
if (
|
||||
advanceThreshold &&
|
||||
|
||||
@@ -395,6 +395,7 @@ export async function findLeanById(id: number) {
|
||||
.where("User.id", "=", id)
|
||||
.select(({ eb }) => [
|
||||
...commonUserSelect(eb),
|
||||
"User.createdAt",
|
||||
"User.customTheme",
|
||||
"User.isArtist",
|
||||
"User.isVideoAdder",
|
||||
|
||||
@@ -1,93 +1,58 @@
|
||||
import type { UserReportCategory } from "~/db/tables";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { SENDOU_INK_BASE_URL, sendouQMatchPage, userPage } from "~/utils/urls";
|
||||
import {
|
||||
sendModDiscordWebhook,
|
||||
truncateEmbedValue,
|
||||
userAdminPageLink,
|
||||
userPageLink,
|
||||
type WebhookUser,
|
||||
} from "~/modules/discord-webhook.server";
|
||||
import { SENDOU_INK_BASE_URL, sendouQMatchPage } from "~/utils/urls";
|
||||
import { USER_REPORT_CATEGORY_LABELS } from "../user-report-constants";
|
||||
|
||||
const EMBED_DESCRIPTION_MAX_LENGTH = 1000;
|
||||
|
||||
/**
|
||||
* Posts a rich embed about a new/updated user report to the mod channel Discord webhook.
|
||||
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
|
||||
* line when `USER_REPORT_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
|
||||
* Fire-and-forget (see `sendModDiscordWebhook`).
|
||||
*/
|
||||
export function sendUserReportWebhook(args: {
|
||||
reportedUser: { id: number; username: string };
|
||||
reporter: { username: string; discordId: string; customUrl: string | null };
|
||||
reportedUser: WebhookUser;
|
||||
reporter: WebhookUser;
|
||||
category: UserReportCategory;
|
||||
description: string;
|
||||
matchId: number | null;
|
||||
isUpdate: boolean;
|
||||
reportCounts: { lastMonth: number; lastYear: number };
|
||||
}) {
|
||||
const webhookUrl = process.env.USER_REPORT_DISCORD_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
logger.info(
|
||||
"USER_REPORT_DISCORD_WEBHOOK_URL not set, skipping user report webhook",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const reportedUserAdminUrl = `${SENDOU_INK_BASE_URL}/u/${args.reportedUser.id}/admin`;
|
||||
const reporterUrl = `${SENDOU_INK_BASE_URL}${userPage(args.reporter)}`;
|
||||
|
||||
const body = {
|
||||
embeds: [
|
||||
sendModDiscordWebhook({
|
||||
title: args.isUpdate ? "User report updated" : "New user report",
|
||||
fields: [
|
||||
{
|
||||
title: args.isUpdate ? "User report updated" : "New user report",
|
||||
fields: [
|
||||
{
|
||||
name: "Reported user",
|
||||
value: `[${args.reportedUser.username}](${reportedUserAdminUrl})`,
|
||||
},
|
||||
{
|
||||
name: "Reporter",
|
||||
value: `[${args.reporter.username}](${reporterUrl})`,
|
||||
},
|
||||
{
|
||||
name: "Category",
|
||||
value: USER_REPORT_CATEGORY_LABELS[args.category],
|
||||
},
|
||||
{
|
||||
name: "Description",
|
||||
value: truncate(args.description),
|
||||
},
|
||||
...(args.matchId !== null
|
||||
? [
|
||||
{
|
||||
name: "SendouQ match",
|
||||
value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Reports against this user",
|
||||
value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`,
|
||||
},
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
name: "Reported user",
|
||||
value: userAdminPageLink(args.reportedUser),
|
||||
},
|
||||
{
|
||||
name: "Reporter",
|
||||
value: userPageLink(args.reporter),
|
||||
},
|
||||
{
|
||||
name: "Category",
|
||||
value: USER_REPORT_CATEGORY_LABELS[args.category],
|
||||
},
|
||||
{
|
||||
name: "Description",
|
||||
value: truncateEmbedValue(args.description),
|
||||
},
|
||||
...(args.matchId !== null
|
||||
? [
|
||||
{
|
||||
name: "SendouQ match",
|
||||
value: `[#${args.matchId}](${SENDOU_INK_BASE_URL}${sendouQMatchPage(args.matchId)})`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "Reports against this user",
|
||||
value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`User report webhook responded with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to send user report webhook", error);
|
||||
});
|
||||
}
|
||||
|
||||
function truncate(description: string) {
|
||||
if (description.length <= EMBED_DESCRIPTION_MAX_LENGTH) return description;
|
||||
|
||||
return `${description.slice(0, EMBED_DESCRIPTION_MAX_LENGTH)}…`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,30 @@ describe("extractYoutubeIdFromVideoUrl", () => {
|
||||
expect(result).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("should strip share tracking params from a shortened YouTube URL", () => {
|
||||
const url = "https://youtu.be/fuj_pSAbU-A?si=mAzDxgrIJWLO1ykq";
|
||||
const result = extractYoutubeIdFromVideoUrl(url);
|
||||
expect(result).toBe("fuj_pSAbU-A");
|
||||
});
|
||||
|
||||
it("should strip extra query params from a standard YouTube URL", () => {
|
||||
const url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=120";
|
||||
const result = extractYoutubeIdFromVideoUrl(url);
|
||||
expect(result).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("should strip query params from a YouTube live URL", () => {
|
||||
const url = "https://www.youtube.com/live/dQw4w9WgXcQ?feature=shared";
|
||||
const result = extractYoutubeIdFromVideoUrl(url);
|
||||
expect(result).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("should strip url fragments", () => {
|
||||
const url = "https://youtu.be/dQw4w9WgXcQ#t=1m";
|
||||
const result = extractYoutubeIdFromVideoUrl(url);
|
||||
expect(result).toBe("dQw4w9WgXcQ");
|
||||
});
|
||||
|
||||
it("should return null for an invalid YouTube URL", () => {
|
||||
const url = "https://www.example.com/watch?v=dQw4w9WgXcQ";
|
||||
const result = extractYoutubeIdFromVideoUrl(url);
|
||||
|
||||
@@ -52,7 +52,7 @@ export function canEditVideo({
|
||||
|
||||
export function extractYoutubeIdFromVideoUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|live\/)|youtu\.be\/)([^&/?]+)/,
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|live\/)|youtu\.be\/)([^&/?#]+)/,
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
77
app/modules/discord-webhook.server.ts
Normal file
77
app/modules/discord-webhook.server.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { logger } from "~/utils/logger";
|
||||
import { SENDOU_INK_BASE_URL, userAdminPage, userPage } from "~/utils/urls";
|
||||
|
||||
const EMBED_FIELD_VALUE_MAX_LENGTH = 1000;
|
||||
|
||||
interface ModWebhookEmbed {
|
||||
title: string;
|
||||
fields: Array<{ name: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface WebhookUser {
|
||||
username: string;
|
||||
discordId: string;
|
||||
customUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a rich embed to the mod channel Discord webhook.
|
||||
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
|
||||
* line when `MOD_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
|
||||
*/
|
||||
export function sendModDiscordWebhook(embed: ModWebhookEmbed) {
|
||||
const webhookUrl = process.env.MOD_DISCORD_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
logger.info(
|
||||
"MOD_DISCORD_WEBHOOK_URL not set, skipping mod Discord webhook",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
embeds: [{ ...embed, timestamp: new Date().toISOString() }],
|
||||
allowed_mentions: { parse: [] },
|
||||
};
|
||||
|
||||
fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
logger.error(
|
||||
`Mod Discord webhook responded with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error("Failed to send mod Discord webhook", error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes then truncates free-text user input so it fits inside a Discord embed field
|
||||
* value without breaking the embed's markdown formatting.
|
||||
*/
|
||||
export function truncateEmbedValue(text: string) {
|
||||
const escaped = escapeMarkdown(text);
|
||||
if (escaped.length <= EMBED_FIELD_VALUE_MAX_LENGTH) return escaped;
|
||||
|
||||
return `${escaped.slice(0, EMBED_FIELD_VALUE_MAX_LENGTH)}…`;
|
||||
}
|
||||
|
||||
/** Markdown link to the user's admin tab (moderation actions & notes). */
|
||||
export function userAdminPageLink(user: WebhookUser) {
|
||||
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userAdminPage(user)})`;
|
||||
}
|
||||
|
||||
/** Markdown link to the user's profile page. */
|
||||
export function userPageLink(user: WebhookUser) {
|
||||
return `[${escapeMarkdown(user.username)}](${SENDOU_INK_BASE_URL}${userPage(user)})`;
|
||||
}
|
||||
|
||||
/** Backslash-escapes Discord markdown so user text can't forge links or break formatting. */
|
||||
function escapeMarkdown(text: string) {
|
||||
return text.replace(/[\\`*_~|()[\]]/g, "\\$&");
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import tournament from "../../../locales/en/tournament.json";
|
||||
import user from "../../../locales/en/user.json";
|
||||
import vods from "../../../locales/en/vods.json";
|
||||
import weapons from "../../../locales/en/weapons.json";
|
||||
import welcome from "../../../locales/en/welcome.json";
|
||||
|
||||
export const resources = {
|
||||
en: {
|
||||
@@ -51,5 +52,6 @@ export const resources = {
|
||||
user,
|
||||
vods,
|
||||
weapons,
|
||||
welcome,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import tournamentDa from "../../../locales/da/tournament.json";
|
||||
import userDa from "../../../locales/da/user.json";
|
||||
import vodsDa from "../../../locales/da/vods.json";
|
||||
import weaponsDa from "../../../locales/da/weapons.json";
|
||||
import welcomeDa from "../../../locales/da/welcome.json";
|
||||
import analyzerDe from "../../../locales/de/analyzer.json";
|
||||
import artDe from "../../../locales/de/art.json";
|
||||
import badgesDe from "../../../locales/de/badges.json";
|
||||
@@ -50,6 +51,7 @@ import tournamentDe from "../../../locales/de/tournament.json";
|
||||
import userDe from "../../../locales/de/user.json";
|
||||
import vodsDe from "../../../locales/de/vods.json";
|
||||
import weaponsDe from "../../../locales/de/weapons.json";
|
||||
import welcomeDe from "../../../locales/de/welcome.json";
|
||||
import analyzer from "../../../locales/en/analyzer.json";
|
||||
import art from "../../../locales/en/art.json";
|
||||
import badges from "../../../locales/en/badges.json";
|
||||
@@ -76,6 +78,7 @@ import tournament from "../../../locales/en/tournament.json";
|
||||
import user from "../../../locales/en/user.json";
|
||||
import vods from "../../../locales/en/vods.json";
|
||||
import weapons from "../../../locales/en/weapons.json";
|
||||
import welcomeEn from "../../../locales/en/welcome.json";
|
||||
import analyzerEsEs from "../../../locales/es-ES/analyzer.json";
|
||||
import artEsEs from "../../../locales/es-ES/art.json";
|
||||
import badgesEsEs from "../../../locales/es-ES/badges.json";
|
||||
@@ -102,6 +105,7 @@ import tournamentEsEs from "../../../locales/es-ES/tournament.json";
|
||||
import userEsEs from "../../../locales/es-ES/user.json";
|
||||
import vodsEsEs from "../../../locales/es-ES/vods.json";
|
||||
import weaponsEsEs from "../../../locales/es-ES/weapons.json";
|
||||
import welcomeEsEs from "../../../locales/es-ES/welcome.json";
|
||||
import analyzerEsUs from "../../../locales/es-US/analyzer.json";
|
||||
import artEsUs from "../../../locales/es-US/art.json";
|
||||
import badgesEsUs from "../../../locales/es-US/badges.json";
|
||||
@@ -128,6 +132,7 @@ import tournamentEsUs from "../../../locales/es-US/tournament.json";
|
||||
import userEsUs from "../../../locales/es-US/user.json";
|
||||
import vodsEsUs from "../../../locales/es-US/vods.json";
|
||||
import weaponsEsUs from "../../../locales/es-US/weapons.json";
|
||||
import welcomeEsUs from "../../../locales/es-US/welcome.json";
|
||||
import analyzerFrCa from "../../../locales/fr-CA/analyzer.json";
|
||||
import artFrCa from "../../../locales/fr-CA/art.json";
|
||||
import badgesFrCa from "../../../locales/fr-CA/badges.json";
|
||||
@@ -154,6 +159,7 @@ import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
|
||||
import userFrCa from "../../../locales/fr-CA/user.json";
|
||||
import vodsFrCa from "../../../locales/fr-CA/vods.json";
|
||||
import weaponsFrCa from "../../../locales/fr-CA/weapons.json";
|
||||
import welcomeFrCa from "../../../locales/fr-CA/welcome.json";
|
||||
import analyzerFrEu from "../../../locales/fr-EU/analyzer.json";
|
||||
import artFrEu from "../../../locales/fr-EU/art.json";
|
||||
import badgesFrEu from "../../../locales/fr-EU/badges.json";
|
||||
@@ -180,6 +186,7 @@ import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
|
||||
import userFrEu from "../../../locales/fr-EU/user.json";
|
||||
import vodsFrEu from "../../../locales/fr-EU/vods.json";
|
||||
import weaponsFrEu from "../../../locales/fr-EU/weapons.json";
|
||||
import welcomeFrEu from "../../../locales/fr-EU/welcome.json";
|
||||
import analyzerHe from "../../../locales/he/analyzer.json";
|
||||
import artHe from "../../../locales/he/art.json";
|
||||
import badgesHe from "../../../locales/he/badges.json";
|
||||
@@ -206,6 +213,7 @@ import tournamentHe from "../../../locales/he/tournament.json";
|
||||
import userHe from "../../../locales/he/user.json";
|
||||
import vodsHe from "../../../locales/he/vods.json";
|
||||
import weaponsHe from "../../../locales/he/weapons.json";
|
||||
import welcomeHe from "../../../locales/he/welcome.json";
|
||||
import analyzerIt from "../../../locales/it/analyzer.json";
|
||||
import artIt from "../../../locales/it/art.json";
|
||||
import badgesIt from "../../../locales/it/badges.json";
|
||||
@@ -232,6 +240,7 @@ import tournamentIt from "../../../locales/it/tournament.json";
|
||||
import userIt from "../../../locales/it/user.json";
|
||||
import vodsIt from "../../../locales/it/vods.json";
|
||||
import weaponsIt from "../../../locales/it/weapons.json";
|
||||
import welcomeIt from "../../../locales/it/welcome.json";
|
||||
import analyzerJa from "../../../locales/ja/analyzer.json";
|
||||
import artJa from "../../../locales/ja/art.json";
|
||||
import badgesJa from "../../../locales/ja/badges.json";
|
||||
@@ -258,6 +267,7 @@ import tournamentJa from "../../../locales/ja/tournament.json";
|
||||
import userJa from "../../../locales/ja/user.json";
|
||||
import vodsJa from "../../../locales/ja/vods.json";
|
||||
import weaponsJa from "../../../locales/ja/weapons.json";
|
||||
import welcomeJa from "../../../locales/ja/welcome.json";
|
||||
import analyzerKo from "../../../locales/ko/analyzer.json";
|
||||
import artKo from "../../../locales/ko/art.json";
|
||||
import badgesKo from "../../../locales/ko/badges.json";
|
||||
@@ -284,6 +294,7 @@ import tournamentKo from "../../../locales/ko/tournament.json";
|
||||
import userKo from "../../../locales/ko/user.json";
|
||||
import vodsKo from "../../../locales/ko/vods.json";
|
||||
import weaponsKo from "../../../locales/ko/weapons.json";
|
||||
import welcomeKo from "../../../locales/ko/welcome.json";
|
||||
import analyzerNl from "../../../locales/nl/analyzer.json";
|
||||
import artNl from "../../../locales/nl/art.json";
|
||||
import badgesNl from "../../../locales/nl/badges.json";
|
||||
@@ -310,6 +321,7 @@ import tournamentNl from "../../../locales/nl/tournament.json";
|
||||
import userNl from "../../../locales/nl/user.json";
|
||||
import vodsNl from "../../../locales/nl/vods.json";
|
||||
import weaponsNl from "../../../locales/nl/weapons.json";
|
||||
import welcomeNl from "../../../locales/nl/welcome.json";
|
||||
import analyzerPl from "../../../locales/pl/analyzer.json";
|
||||
import artPl from "../../../locales/pl/art.json";
|
||||
import badgesPl from "../../../locales/pl/badges.json";
|
||||
@@ -336,6 +348,7 @@ import tournamentPl from "../../../locales/pl/tournament.json";
|
||||
import userPl from "../../../locales/pl/user.json";
|
||||
import vodsPl from "../../../locales/pl/vods.json";
|
||||
import weaponsPl from "../../../locales/pl/weapons.json";
|
||||
import welcomePl from "../../../locales/pl/welcome.json";
|
||||
import analyzerPtBr from "../../../locales/pt-BR/analyzer.json";
|
||||
import artPtBr from "../../../locales/pt-BR/art.json";
|
||||
import badgesPtBr from "../../../locales/pt-BR/badges.json";
|
||||
@@ -362,6 +375,7 @@ import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
|
||||
import userPtBr from "../../../locales/pt-BR/user.json";
|
||||
import vodsPtBr from "../../../locales/pt-BR/vods.json";
|
||||
import weaponsPtBr from "../../../locales/pt-BR/weapons.json";
|
||||
import welcomePtBr from "../../../locales/pt-BR/welcome.json";
|
||||
import analyzerRu from "../../../locales/ru/analyzer.json";
|
||||
import artRu from "../../../locales/ru/art.json";
|
||||
import badgesRu from "../../../locales/ru/badges.json";
|
||||
@@ -388,6 +402,7 @@ import tournamentRu from "../../../locales/ru/tournament.json";
|
||||
import userRu from "../../../locales/ru/user.json";
|
||||
import vodsRu from "../../../locales/ru/vods.json";
|
||||
import weaponsRu from "../../../locales/ru/weapons.json";
|
||||
import welcomeRu from "../../../locales/ru/welcome.json";
|
||||
import analyzerZh from "../../../locales/zh/analyzer.json";
|
||||
import artZh from "../../../locales/zh/art.json";
|
||||
import badgesZh from "../../../locales/zh/badges.json";
|
||||
@@ -414,6 +429,7 @@ import tournamentZh from "../../../locales/zh/tournament.json";
|
||||
import userZh from "../../../locales/zh/user.json";
|
||||
import vodsZh from "../../../locales/zh/vods.json";
|
||||
import weaponsZh from "../../../locales/zh/weapons.json";
|
||||
import welcomeZh from "../../../locales/zh/welcome.json";
|
||||
|
||||
export const resources = {
|
||||
"es-US": {
|
||||
@@ -443,6 +459,7 @@ export const resources = {
|
||||
team: teamEsUs,
|
||||
"tier-list-maker": tierListMakerEsUs,
|
||||
analyzer: analyzerEsUs,
|
||||
welcome: welcomeEsUs,
|
||||
},
|
||||
en: {
|
||||
gear: gear,
|
||||
@@ -471,6 +488,7 @@ export const resources = {
|
||||
team: team,
|
||||
"tier-list-maker": tierListMaker,
|
||||
analyzer: analyzer,
|
||||
welcome: welcomeEn,
|
||||
},
|
||||
ko: {
|
||||
gear: gearKo,
|
||||
@@ -499,6 +517,7 @@ export const resources = {
|
||||
team: teamKo,
|
||||
"tier-list-maker": tierListMakerKo,
|
||||
analyzer: analyzerKo,
|
||||
welcome: welcomeKo,
|
||||
},
|
||||
de: {
|
||||
gear: gearDe,
|
||||
@@ -527,6 +546,7 @@ export const resources = {
|
||||
team: teamDe,
|
||||
"tier-list-maker": tierListMakerDe,
|
||||
analyzer: analyzerDe,
|
||||
welcome: welcomeDe,
|
||||
},
|
||||
nl: {
|
||||
gear: gearNl,
|
||||
@@ -555,6 +575,7 @@ export const resources = {
|
||||
team: teamNl,
|
||||
"tier-list-maker": tierListMakerNl,
|
||||
analyzer: analyzerNl,
|
||||
welcome: welcomeNl,
|
||||
},
|
||||
"pt-BR": {
|
||||
gear: gearPtBr,
|
||||
@@ -583,6 +604,7 @@ export const resources = {
|
||||
team: teamPtBr,
|
||||
"tier-list-maker": tierListMakerPtBr,
|
||||
analyzer: analyzerPtBr,
|
||||
welcome: welcomePtBr,
|
||||
},
|
||||
zh: {
|
||||
gear: gearZh,
|
||||
@@ -611,6 +633,7 @@ export const resources = {
|
||||
team: teamZh,
|
||||
"tier-list-maker": tierListMakerZh,
|
||||
analyzer: analyzerZh,
|
||||
welcome: welcomeZh,
|
||||
},
|
||||
"fr-CA": {
|
||||
gear: gearFrCa,
|
||||
@@ -639,6 +662,7 @@ export const resources = {
|
||||
team: teamFrCa,
|
||||
"tier-list-maker": tierListMakerFrCa,
|
||||
analyzer: analyzerFrCa,
|
||||
welcome: welcomeFrCa,
|
||||
},
|
||||
ru: {
|
||||
gear: gearRu,
|
||||
@@ -667,6 +691,7 @@ export const resources = {
|
||||
team: teamRu,
|
||||
"tier-list-maker": tierListMakerRu,
|
||||
analyzer: analyzerRu,
|
||||
welcome: welcomeRu,
|
||||
},
|
||||
it: {
|
||||
gear: gearIt,
|
||||
@@ -695,6 +720,7 @@ export const resources = {
|
||||
team: teamIt,
|
||||
"tier-list-maker": tierListMakerIt,
|
||||
analyzer: analyzerIt,
|
||||
welcome: welcomeIt,
|
||||
},
|
||||
ja: {
|
||||
gear: gearJa,
|
||||
@@ -723,6 +749,7 @@ export const resources = {
|
||||
team: teamJa,
|
||||
"tier-list-maker": tierListMakerJa,
|
||||
analyzer: analyzerJa,
|
||||
welcome: welcomeJa,
|
||||
},
|
||||
da: {
|
||||
gear: gearDa,
|
||||
@@ -751,6 +778,7 @@ export const resources = {
|
||||
team: teamDa,
|
||||
"tier-list-maker": tierListMakerDa,
|
||||
analyzer: analyzerDa,
|
||||
welcome: welcomeDa,
|
||||
},
|
||||
"es-ES": {
|
||||
gear: gearEsEs,
|
||||
@@ -779,6 +807,7 @@ export const resources = {
|
||||
team: teamEsEs,
|
||||
"tier-list-maker": tierListMakerEsEs,
|
||||
analyzer: analyzerEsEs,
|
||||
welcome: welcomeEsEs,
|
||||
},
|
||||
he: {
|
||||
gear: gearHe,
|
||||
@@ -807,6 +836,7 @@ export const resources = {
|
||||
team: teamHe,
|
||||
"tier-list-maker": tierListMakerHe,
|
||||
analyzer: analyzerHe,
|
||||
welcome: welcomeHe,
|
||||
},
|
||||
"fr-EU": {
|
||||
gear: gearFrEu,
|
||||
@@ -835,6 +865,7 @@ export const resources = {
|
||||
team: teamFrEu,
|
||||
"tier-list-maker": tierListMakerFrEu,
|
||||
analyzer: analyzerFrEu,
|
||||
welcome: welcomeFrEu,
|
||||
},
|
||||
pl: {
|
||||
gear: gearPl,
|
||||
@@ -863,6 +894,7 @@ export const resources = {
|
||||
team: teamPl,
|
||||
"tier-list-maker": tierListMakerPl,
|
||||
analyzer: analyzerPl,
|
||||
welcome: welcomePl,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
languages: user.languages ? user.languages.split(",") : [],
|
||||
plusTier: user.plusTier,
|
||||
roles: user.roles,
|
||||
createdAt: user.createdAt,
|
||||
}
|
||||
: undefined,
|
||||
customTheme: isSupporter(user) ? user?.customTheme : undefined,
|
||||
|
||||
@@ -208,6 +208,7 @@ export default [
|
||||
]),
|
||||
|
||||
route("/faq", "features/info/routes/faq.tsx"),
|
||||
route("/welcome", "features/info/routes/welcome.tsx"),
|
||||
route("/contributions", "features/info/routes/contributions.tsx"),
|
||||
route("/support", "features/info/routes/support.tsx"),
|
||||
|
||||
|
||||
@@ -284,6 +284,29 @@
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
}
|
||||
|
||||
.welcomeBanner {
|
||||
background-color: var(--color-bg-higher);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semi);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-high);
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.lutiBanner {
|
||||
background-color: #4874a0;
|
||||
color: #fff;
|
||||
|
||||
@@ -11,3 +11,10 @@ export class ConcurrentModificationError extends Error {
|
||||
this.name = "ConcurrentModificationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateEntryError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DuplicateEntryError";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const ALL_NAMESPACES = [
|
||||
"friends",
|
||||
"settings",
|
||||
"params",
|
||||
"welcome",
|
||||
] as const;
|
||||
assertType<Namespace, (typeof ALL_NAMESPACES)[number]>();
|
||||
assertType<(typeof ALL_NAMESPACES)[number], Namespace>();
|
||||
|
||||
41
app/utils/remix.test.ts
Normal file
41
app/utils/remix.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Location } from "react-router";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { metaTags } from "./remix";
|
||||
import { COMMON_PREVIEW_IMAGE } from "./urls";
|
||||
|
||||
const location = { pathname: "/to/1/brackets" } as Location;
|
||||
|
||||
const contentOf = (tags: ReturnType<typeof metaTags>, property: string) =>
|
||||
tags.find((tag) => "property" in tag && tag.property === property)?.content;
|
||||
|
||||
describe("metaTags()", () => {
|
||||
it("uses the common preview image when no image given", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location });
|
||||
|
||||
expect(contentOf(tags, "og:image")).toBe(COMMON_PREVIEW_IMAGE);
|
||||
});
|
||||
|
||||
it("uses the given image url", () => {
|
||||
const tags = metaTags({
|
||||
title: "sendou.ink",
|
||||
location,
|
||||
image: { url: "https://cdn.example.com/img/preview.png" },
|
||||
});
|
||||
|
||||
expect(contentOf(tags, "og:image")).toBe(
|
||||
"https://cdn.example.com/img/preview.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves og:url from the location pathname", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location });
|
||||
|
||||
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1/brackets");
|
||||
});
|
||||
|
||||
it("prefers the url override over the location pathname", () => {
|
||||
const tags = metaTags({ title: "sendou.ink", location, url: "/to/1" });
|
||||
|
||||
expect(contentOf(tags, "og:url")).toBe("https://sendou.ink/to/1");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ interface OpenGraphArgs {
|
||||
/** Optionally override location pathname. */
|
||||
url?: string;
|
||||
image?: {
|
||||
/** Absolute URL of the image. */
|
||||
url: string;
|
||||
dimensions?: {
|
||||
width: number;
|
||||
@@ -80,21 +81,11 @@ export function metaTags(args: OpenGraphArgs) {
|
||||
},
|
||||
{
|
||||
property: "og:url",
|
||||
content: `${ROOT_URL}${args.location.pathname}`,
|
||||
content: `${ROOT_URL}${args.url ?? args.location.pathname}`,
|
||||
},
|
||||
{
|
||||
property: "og:image",
|
||||
content: (() => {
|
||||
if (args.image?.url.startsWith("http")) {
|
||||
return args.image.url;
|
||||
}
|
||||
|
||||
if (args.image) {
|
||||
return `${ROOT_URL}${args.image.url}`;
|
||||
}
|
||||
|
||||
return `${ROOT_URL}${COMMON_PREVIEW_IMAGE}`;
|
||||
})(),
|
||||
content: args.image?.url ?? COMMON_PREVIEW_IMAGE,
|
||||
},
|
||||
].filter((val) => val !== null);
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export const ADMIN_PAGE = "/admin";
|
||||
export const API_PAGE = "/api";
|
||||
export const ARTICLES_MAIN_PAGE = "/a";
|
||||
export const FAQ_PAGE = "/faq";
|
||||
export const WELCOME_PAGE = "/welcome";
|
||||
export const SUPPORT_PAGE = "/support";
|
||||
export const CONTRIBUTIONS_PAGE = "/contributions";
|
||||
export const BADGES_PAGE = "/badges";
|
||||
@@ -128,6 +129,7 @@ export const SENDOU_LOVE_EMOJI_PATH = `${STATIC_ASSETS_URL}/img/layout/sendou_lo
|
||||
export const FIRST_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/first.svg`;
|
||||
export const SECOND_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/second.svg`;
|
||||
export const THIRD_PLACEMENT_ICON_PATH = `${STATIC_ASSETS_URL}/svg/placements/third.svg`;
|
||||
export const WELCOME_HERO_IMAGE_PATH = `${STATIC_ASSETS_URL}/img/welcome-hero.webp`;
|
||||
|
||||
export const APP_ICON_URL = `${STATIC_ASSETS_URL}/img/app-icon.png`;
|
||||
export const pwaSplashScreenImageUrl = (fileName: string) =>
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -4,6 +4,7 @@ import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
SETTINGS_PAGE,
|
||||
sendouQInviteLink,
|
||||
} from "~/utils/urls";
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
seed,
|
||||
submit,
|
||||
test,
|
||||
waitForPOSTResponse,
|
||||
} from "./helpers/playwright";
|
||||
|
||||
test.describe("SendouQ", () => {
|
||||
@@ -140,4 +142,32 @@ test.describe("SendouQ", () => {
|
||||
combinedGroup.getByTestId("sendouq-group-card-member"),
|
||||
).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("Changing match preferences cancels pending requests", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seed(page);
|
||||
|
||||
// Sendou (ADMIN) is in a full group. Challenge another full group.
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
|
||||
await waitForPOSTResponse(page, () =>
|
||||
page.getByRole("button", { name: "Challenge" }).first().click(),
|
||||
);
|
||||
|
||||
// The challenge is now pending and can be undone
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(1);
|
||||
|
||||
// Changing a matchmaking preference (noScreen) last second must undo the
|
||||
// pending request so it can't be matched on terms the challenger never saw
|
||||
await navigate({ page, url: `${SETTINGS_PAGE}?tab=match-profile` });
|
||||
await page
|
||||
.getByRole("switch", { name: /Avoid Splattercolor Screen/i })
|
||||
.click({ force: true });
|
||||
await submit(page);
|
||||
|
||||
// The pending challenge has been undone
|
||||
await navigate({ page, url: SENDOUQ_LOOKING_PAGE });
|
||||
await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
20
e2e/welcome.spec.ts
Normal file
20
e2e/welcome.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, navigate, seed, test } from "./helpers/playwright";
|
||||
|
||||
test.describe("Welcome", () => {
|
||||
test("navigates to the welcome page via the front page banner when not logged in", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seed(page);
|
||||
await navigate({ page, url: "/" });
|
||||
|
||||
await page
|
||||
.getByRole("link", { name: "New to competitive Splatoon? Start here!" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "Introduction to competitive Splatoon and sendou.ink",
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Log ud",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "",
|
||||
"sq.prepare": "",
|
||||
"sq.participate": "",
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/da/welcome.json
Normal file
27
locales/da/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Ausloggen",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "",
|
||||
"sq.prepare": "",
|
||||
"sq.participate": "",
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/de/welcome.json
Normal file
27
locales/de/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "Settings",
|
||||
"pages.tier-list-maker": "Tier Lists",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "Welcome",
|
||||
"header.profile": "Profile",
|
||||
"header.logout": "Log out",
|
||||
"header.login.discord": "Log in via Discord",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "Tools",
|
||||
"nav.community": "Community",
|
||||
"nav.weapons": "Weapons",
|
||||
"welcomeBanner": "New to competitive Splatoon? Start here!",
|
||||
"sq.season": "Season {{nth}}",
|
||||
"sq.prepare": "Prepare now!",
|
||||
"sq.participate": "Participate now!",
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
|
||||
"progression.error.NAME_MISSING": "Bracket name missing",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
|
||||
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
|
||||
"progression.error.NO_SE_POSITIVE": "Single elimination is not valid for positive progression",
|
||||
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets",
|
||||
|
||||
27
locales/en/welcome.json
Normal file
27
locales/en/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "Introduction to competitive Splatoon and sendou.ink",
|
||||
"photoCredit": "Photo by Dio",
|
||||
"whatDoINeed.header": "What do I need?",
|
||||
"whatDoINeed.body": "You don't need to be any specific skill level to join the community. There are people of all skill levels in the community. Typically you can expect to play mostly against people in the X Rank even in the lower level tournaments but even that is not a requirement. It can be more fun to improve in a group.\n\nDiscord account in practice will be needed as a lot of the communication within teams and tournaments happens there. You can also use a Discord account to log in to sendou.ink.\n\nFor voice chat players typically use Discord. In terms of setting it up you can search online for more information but some people might use an audio splitter that allows you to hear both in-game audio and voice chat with the same headphones. Simpler setup might be to use headphones from your phone for example and use speakers for in-game audio.\n\nYou should also invest in an ethernet cable. Stable internet makes the game more enjoyable for yourself and people who play against you.",
|
||||
"motionControls.header": "Should I use motion controls?",
|
||||
"motionControls.body": "In short if you are able to, yes. This is somewhat unique to Splatoon but all high and top level players practically without exception use motion controls. Stick controls differ from most other console shooters in the way that there is no aim assist. In practice using them means you are at a disadvantage.",
|
||||
"weapons.header": "How to select what weapons to play?",
|
||||
"weapons.body": "If you are just starting out worrying too much about what weapon is popular right now in the meta-game is not necessary. This is something that changes rapidly from patch to patch and typically only starts to matter at the very high level of play. Instead choose a weapon to get good at that you enjoy playing. Then consider complementing it with 1-2 other alternative weapons that are different from it (in kit or range for example). This makes it easier for you to fit the team composition no matter what your teammates are playing.",
|
||||
"builds.header": "How to make a good build?",
|
||||
"builds.body": "Min-maxing builds can be fun but it's important to realize that how well you do in matches is overwhelmingly decided by your skill rather than the exact build you are using. Only rule you really need to remember is diminishing returns meaning the first sub of any given ability is going to be the most effective and the more you are stacking, the less of an effect you will be seeing. That's why typically mixing at least a handful of different abilities is likely going to be the strongest build. That said instead of reinventing the wheel you can just check what experienced players are using via the >>builds<< listing.\n\nIf you are someone who likes to optimize everything down to the last sub then make sure to check out >>analyzer<< and >>object-damage-calculator<< to learn exactly what each ability does.",
|
||||
"mapsModes.header": "What maps/modes are played competitively?",
|
||||
"mapsModes.body": "This will completely depend on the tournament. Before signing up for a tournament on sendou.ink you will be able to see the maps and modes the tournament is running. For higher level of play and some open tournaments Splat Zones only is popular. Turf War is less popular in competitive than other modes but there are also tournaments that include it. Notably Koshien in Japan which is one of the most prestigious tournaments is only Turf War.",
|
||||
"findingTeam.header": "How can I find a team?",
|
||||
"findingTeam.body": "Browse the posts on >>lfg<< or leave your own!",
|
||||
"noTeam.header": "I don't have a team yet, can I still play?",
|
||||
"noTeam.body": "Team or no team solo practice is crucial for improving. Even the best of the best players have put in a significant amount of hours practicing solo ranked (X Rank). You also sometimes see people use peak X Rank power (XP) as a skill indicator. As a newer player one of your main goals should be reaching X Rank and then improving your rank. One of the pitfalls is despairing over what your teammates are doing in any given match. Do not fall for this, as this is not something you can affect and the best players can turn even the most unwinnable matches around. For divisions Tentatek can be good when starting but you will find most of the competitive players choosing the Takoroka division which is something to keep in mind.\n\nSendouQ is an extension of the in-game solo. It allows you to queue alone or with 1-3 of your friends. There is a built-in solution to form a team on the spot. When signing up solo be prepared for it to take some time. Typically you would want to for example play solo in the background and check back to how the team forming is going between games. Remember to also set your full match profile on the >>settings<< page.\n\nYou don't need a team to start playing in tournaments. Check out >>calendar<< and utilize filters to find out tournaments that are either 1vs1 format or are \"draft\" format. 1vs1 is not the typical competitive format (that would be 4vs4) but it's a good way to get your feet wet. Draft tournaments have the organizer form teams from the solo sign-ups. Even if the tournament does not have solo sign-ups as such you can use the LFG feature to indicate you are looking for a team for the event. The actual process works similarly to SendouQ team forming.\n\nGood way to improve is also to watch and study good players that play the same weapons as you! The >>vods<< feature is one way to discover them.",
|
||||
"haveTeam.header": "I have a team, what should we do?",
|
||||
"haveTeam.body": "Never too early to participate in some tournaments! On the >>calendar<< you can filter by the \"Skill cap\" tag to find tournaments specifically catered to newer teams. That said don't be shy to try some open level tournaments as well. It's a good way to avoid building bad habits around strategies that won't work against higher level teams.\n\nOutside of tournaments >>sendouq<< is in fact best experienced with a pre-made team. It can give you that tournament like pressure of a \"best of\" set while you are practicing a specific map list, maybe for some of your upcoming tournaments.\n\n>>scrims<< are another great option. While map list tracking is supported here it's not really about \"win or lose\". Instead you are trying to learn in an environment where you are able to replay maps and play an extended period of time against the same opponent. Scrims can be scheduled which gives you a better chance to find a suitable opponent than just relying on finding one on the spot.",
|
||||
"ranks.header": "What are ranks on sendou.ink? (Leviathan/Diamond/Platinum…)",
|
||||
"ranks.body": "Play SendouQ and ranked tournaments to participate in sendou.ink competitive seasons. Once you have played at least seven sets, your rank will be calculated and shown. Then you can try to get it higher before season ends! Ranks are always strictly percentile based so for example \"Leviathan\" is always the top 5% of the players who participated in the season. View all ranks on the >>tiers<< page.",
|
||||
"divs.header": "What are \"divs\"?",
|
||||
"divs.body": "Divs or divisions are related to >>luti<< (Leagues Under The Ink) which is the biggest Splatoon league. It has historically been run about once or twice a year. For historical reasons, people sometimes use them as proxy to player skill. So if someone is saying they are \"Div 2\" that would mean their skill level is higher than \"Div 4\". The actual way to get a Div is to participate in LUTI (which you should participate in because it's a very special event, not to get a div) but people also freely estimate them.",
|
||||
"plusServer.header": "What is the \"Plus Server\" (+1/+2/+3)?",
|
||||
"plusServer.body": "Originally a high and top level player LFG server. There is a voting happening once a season where every existing member votes on everyone in that tier as well as all the suggestions that time around. You can also get membership by achieving a high enough rank in a season. See the exact details on the >>faq<< page.",
|
||||
"pills.tiers": "Tiers"
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "Ajustes",
|
||||
"pages.tier-list-maker": "Creador de Tier Lists",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Perfil",
|
||||
"header.logout": "Cerrar sesión",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "Temporada {{nth}}",
|
||||
"sq.prepare": "¡Prepárate ahora!",
|
||||
"sq.participate": "¡Participa ahora!",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "Nombre de cuadro duplicado",
|
||||
"progression.error.NAME_MISSING": "Falta el nombre del cuadro",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble",
|
||||
"progression.error.NO_SE_SOURCE": "La eliminación simple no es un cuadro de origen válido",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/es-ES/welcome.json
Normal file
27
locales/es-ES/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Perfil",
|
||||
"header.logout": "Cerrar sesión",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "",
|
||||
"sq.prepare": "",
|
||||
"sq.participate": "",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/es-US/welcome.json
Normal file
27
locales/es-US/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Déconnexion",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "",
|
||||
"sq.prepare": "",
|
||||
"sq.participate": "",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/fr-CA/welcome.json
Normal file
27
locales/fr-CA/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "Paramètres",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Déconnexion",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "Saison {{nth}}",
|
||||
"sq.prepare": "Preparez-vous maintenant!",
|
||||
"sq.participate": "Participez maintenant!",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
|
||||
"progression.error.NAME_MISSING": "Bracket name missing",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
|
||||
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/fr-EU/welcome.json
Normal file
27
locales/fr-EU/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "הגדרות",
|
||||
"pages.tier-list-maker": "יוצר רשימת רמות",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "פרופיל",
|
||||
"header.logout": "התנתקות",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "",
|
||||
"sq.prepare": "",
|
||||
"sq.participate": "",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "",
|
||||
"progression.error.NAME_MISSING": "",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "",
|
||||
"progression.error.NO_SE_SOURCE": "",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/he/welcome.json
Normal file
27
locales/he/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "Impostazioni",
|
||||
"pages.tier-list-maker": "",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "Profilo",
|
||||
"header.logout": "Esci",
|
||||
"header.login.discord": "",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"nav.tools": "",
|
||||
"nav.community": "",
|
||||
"nav.weapons": "",
|
||||
"welcomeBanner": "",
|
||||
"sq.season": "Stagione {{nth}}",
|
||||
"sq.prepare": "Preparati!",
|
||||
"sq.participate": "Partecipa ora!",
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"progression.error.DUPLICATE_BRACKET_NAME": "Nome bracket duplicato",
|
||||
"progression.error.NAME_MISSING": "Nome bracket mancante",
|
||||
"progression.error.NEGATIVE_PROGRESSION": "La progressione negativa è disponibile solo in doppia eliminazione",
|
||||
"progression.error.NO_SE_SOURCE": "Eliminazione singola non è un bracket sorgente valido",
|
||||
"progression.error.NO_SE_POSITIVE": "",
|
||||
"progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva",
|
||||
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
|
||||
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
|
||||
|
||||
27
locales/it/welcome.json
Normal file
27
locales/it/welcome.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"title": "",
|
||||
"photoCredit": "",
|
||||
"whatDoINeed.header": "",
|
||||
"whatDoINeed.body": "",
|
||||
"motionControls.header": "",
|
||||
"motionControls.body": "",
|
||||
"weapons.header": "",
|
||||
"weapons.body": "",
|
||||
"builds.header": "",
|
||||
"builds.body": "",
|
||||
"mapsModes.header": "",
|
||||
"mapsModes.body": "",
|
||||
"findingTeam.header": "",
|
||||
"findingTeam.body": "",
|
||||
"noTeam.header": "",
|
||||
"noTeam.body": "",
|
||||
"haveTeam.header": "",
|
||||
"haveTeam.body": "",
|
||||
"ranks.header": "",
|
||||
"ranks.body": "",
|
||||
"divs.header": "",
|
||||
"divs.body": "",
|
||||
"plusServer.header": "",
|
||||
"plusServer.body": "",
|
||||
"pills.tiers": ""
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
"pages.settings": "設定",
|
||||
"pages.tier-list-maker": "ティア表作成",
|
||||
"pages.luti": "LUTI",
|
||||
"pages.welcome": "",
|
||||
"header.profile": "プロファイル",
|
||||
"header.logout": "ログアウト",
|
||||
"header.login.discord": "Discord",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user