/q/settings -> /settings (#3085)

This commit is contained in:
Kalle
2026-05-23 18:07:01 +03:00
committed by GitHub
parent f93dd6cc65
commit e41d09981e
108 changed files with 1515 additions and 1654 deletions

View File

@@ -52,6 +52,7 @@
- for any CSS variable used, make sure it is defined either locally or in the `vars.css` file
- for simple styling, prefer [utility classes](./app/styles/utils.css) over creating a new class
- use CSS nesting with the `&` selector to group related selectors (pseudo-classes, pseudo-elements, child selectors, attribute selectors) under their parent instead of repeating the parent selector
- prefer container queries over media queries
## SQL
@@ -76,6 +77,7 @@
- some a11y labels or text that should not normally be encountered by user (example given, error message by server) can be english
- before adding a new translation, check that one doesn't already exist you can reuse (particularly in the common.json)
- add only English translation and use `pnpm run i18n:sync` to initialize other jsons with empty string ready for translators
- when using namespace e.g. `const { t } = useTranslation("settings"]);` it needs to be defined in the `handle` for that route e.g. `export const handle: SendouRouteHandle = { i18n: ["settings"], ... }`
## Commit messages

View File

@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import type { Tables } from "~/db/tables";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";

View File

@@ -94,3 +94,54 @@
z-index: 1;
background-color: var(--color-bg);
}
.vertical {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--s-8);
align-items: start;
& .tabListContainer {
overflow: visible;
position: sticky;
top: var(--layout-sticky-top);
align-self: start;
background-color: var(--color-bg);
z-index: 1;
}
& .tabList {
flex-direction: column;
border-bottom: none;
border-inline-end: 2px solid var(--color-border);
min-width: 0;
}
& .tabContainer {
margin-bottom: 0;
margin-inline-end: -2px;
&[data-selected] .tabButton {
border-bottom-color: transparent;
border-inline-end-color: var(--color-text-accent);
}
}
& .tabButton {
justify-content: flex-start;
border-bottom: none;
border-inline-end: 2px solid transparent;
text-align: start;
flex: none;
padding: var(--s-2) var(--s-3);
padding-inline-end: var(--s-6);
}
& .tabPanel {
min-width: 0;
}
&.padded .tabPanel {
padding-block-start: 0;
}
}

View File

@@ -9,6 +9,7 @@ import {
Tabs,
type TabsProps,
} from "react-aria-components";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import buttonStyles from "./Button.module.css";
import styles from "./Tabs.module.css";
@@ -18,6 +19,8 @@ interface SendouTabsProps extends TabsProps {
padded?: boolean;
/** Hide tabs if only one tab shown? Defaults to true. */
disappearing?: boolean;
/** When orientation is "vertical", switch to horizontal once the main content width drops below this many pixels. */
horizontalBelow?: number;
}
/**
@@ -51,14 +54,31 @@ interface SendouTabsProps extends TabsProps {
export function SendouTabs({
padded = true,
disappearing = true,
horizontalBelow,
className,
orientation,
onSelectionChange,
...rest
}: SendouTabsProps) {
const mainWidth = useMainContentWidth();
const collapsedToHorizontal =
orientation === "vertical" &&
typeof horizontalBelow === "number" &&
mainWidth > 0 &&
mainWidth < horizontalBelow;
const effectiveOrientation = collapsedToHorizontal
? "horizontal"
: orientation;
const isVertical = effectiveOrientation === "vertical";
return (
<Tabs
orientation={effectiveOrientation}
onSelectionChange={onSelectionChange}
className={clsx(className, {
[styles.padded]: padded,
[styles.disappearing]: disappearing,
[styles.vertical]: isVertical,
})}
{...rest}
/>

View File

@@ -13,6 +13,9 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as LFGRepository from "~/features/lfg/LFGRepository.server";
import { TIMEZONES } from "~/features/lfg/lfg-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants";
import * as NotificationRepository from "~/features/notifications/NotificationRepository.server";
import type { Notification } from "~/features/notifications/notifications-types";
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
@@ -26,9 +29,6 @@ import * as ScrimPostRepository from "~/features/scrims/ScrimPostRepository.serv
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/sendouq-settings/q-settings-constants";
import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
@@ -74,9 +74,9 @@ import {
} from "../../../scripts/seed-art-urls";
import type {
ParsedMemento,
QWeaponPool,
Tables,
UserMapModePreferences,
WeaponPoolEntry,
} from "../tables";
import {
ADMIN_TEST_AVATAR,
@@ -183,7 +183,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
adminUserWidgets,
userProfiles,
variation === "TEAM_MAP_PREFS" ? undefined : userMapModePreferences,
userQWeaponPool,
userMatchProfileWeaponPool,
seedingSkills,
lastMonthsVoting,
syncPlusTiers,
@@ -892,7 +892,7 @@ async function userProfiles() {
if (faker.number.float(1) > 0.9) defaultLanguages.push("it");
if (faker.number.float(1) > 0.9) defaultLanguages.push("ja");
await QSettingsRepository.updateVoiceChat({
await MatchProfileRepository.updateVoiceChat({
languages: defaultLanguages,
userId: id,
vc:
@@ -946,7 +946,7 @@ async function userMapModePreferences() {
}
}
async function userQWeaponPool() {
async function userMatchProfileWeaponPool() {
for (let id = 1; id < 500; id++) {
if (id === 2) continue; // no weapons for N-ZAP
if (faker.number.float(1) < 0.2) continue; // 80% have weapons
@@ -955,14 +955,14 @@ async function userQWeaponPool() {
.shuffle(mainWeaponIds)
.slice(0, faker.helpers.arrayElement([1, 2, 3, 4]));
const weaponPool: Array<QWeaponPool> = weapons.map((weaponSplId) => ({
const weaponPool: Array<WeaponPoolEntry> = weapons.map((weaponSplId) => ({
weaponSplId,
isFavorite: faker.number.float(1) > 0.7 ? 1 : 0,
}));
await db
.updateTable("User")
.set({ qWeaponPool: JSON.stringify(weaponPool) })
.set({ weaponPool: JSON.stringify(weaponPool) })
.where("User.id", "=", id)
.execute();
}
@@ -2695,14 +2695,14 @@ async function groups(variation?: SeedVariation | null) {
nzapGroupMemberIds[2],
].filter((id): id is number => typeof id === "number");
for (const userId of guaranteedWeaponPoolUserIds) {
const weapons: QWeaponPool[] = [
const weapons: WeaponPoolEntry[] = [
{ weaponSplId: 0, isFavorite: 1 },
{ weaponSplId: 2000, isFavorite: 0 },
{ weaponSplId: 4000, isFavorite: 0 },
];
await db
.updateTable("User")
.set({ qWeaponPool: JSON.stringify(weapons) })
.set({ weaponPool: JSON.stringify(weapons) })
.where("User.id", "=", userId)
.execute();
}

View File

@@ -978,7 +978,7 @@ export interface UserMapModePreferences {
}>;
}
export interface QWeaponPool {
export interface WeaponPoolEntry {
weaponSplId: MainWeaponId;
isFavorite: number;
}
@@ -1074,7 +1074,7 @@ export interface User {
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
youtubeId: string | null;
mapModePreferences: JSONColumnTypeNullable<UserMapModePreferences>;
qWeaponPool: JSONColumnTypeNullable<QWeaponPool[]>;
weaponPool: JSONColumnTypeNullable<WeaponPoolEntry[]>;
plusSkippedForSeasonNth: number | null;
noScreen: Generated<DBBoolean>;
/** User doesn't have access to SplatNet 3 to join rooms made by others */

View File

@@ -4,8 +4,8 @@ import { getUser } from "~/features/auth/core/user.server";
import * as Changelog from "~/features/front-page/core/Changelog.server";
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as SplatoonRotationRepository from "~/features/splatoon-rotations/SplatoonRotationRepository.server";
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
import { databaseTimestampNow } from "~/utils/dates";
@@ -33,8 +33,8 @@ export const loader = async () => {
cachedLeaderboards(),
SplatoonRotationRepository.findAll(),
user
? QSettingsRepository.settingsByUserId(user.id).then(
(s) => s.qWeaponPool ?? null,
? MatchProfileRepository.settingsByUserId(user.id).then(
(s) => s.weaponPool ?? null,
)
: Promise.resolve(null),
]);

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { LANGUAGE_OPTIONS } from "~/features/sendouq-settings/q-settings-schemas";
import { LANGUAGE_OPTIONS } from "~/features/settings/match-profile-schemas";
import {
checkboxGroup,
idConstantOptional,

View File

@@ -1,7 +1,7 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseSafeSearchParams } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
@@ -14,14 +14,16 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const userProfileData = await UserRepository.findProfileByIdentifier(
String(user.id),
);
const userQSettingsData = await QSettingsRepository.settingsByUserId(user.id);
const userMatchProfile = await MatchProfileRepository.settingsByUserId(
user.id,
);
const allPosts = await LFGRepository.posts(user);
const postToEdit = searchParamsToBuildToEdit(request, user.id, allPosts);
return {
team: userProfileData?.team,
weaponPool: userProfileData?.weapons,
languages: postToEdit?.languages?.split(",") ?? userQSettingsData.languages,
languages: postToEdit?.languages?.split(",") ?? userMatchProfile.languages,
postToEdit,
userPostTypes: userPostTypes(allPosts, user.id),
};

View File

@@ -11,7 +11,7 @@ export async function settingsByUserId(userId: number) {
"User.mapModePreferences",
"User.vc",
"User.languages",
"User.qWeaponPool",
"User.weaponPool",
"User.noScreen",
"User.noSplatnet",
])
@@ -26,12 +26,37 @@ export async function settingsByUserId(userId: number) {
};
}
export async function updateUserMapModePreferences({
export function updateVoiceChat(args: {
userId: number;
vc: Tables["User"]["vc"];
languages: string[];
}) {
return db
.updateTable("User")
.set({
vc: args.vc,
languages: args.languages.length > 0 ? args.languages.join(",") : null,
})
.where("User.id", "=", args.userId)
.execute();
}
export async function updateMatchProfile({
userId,
mapModePreferences,
vc,
languages,
weaponPool,
noScreen,
noSplatnet,
}: {
userId: number;
mapModePreferences: UserMapModePreferences;
vc: Tables["User"]["vc"];
languages: string[];
weaponPool: WeaponPoolItem[];
noScreen: number;
noSplatnet: number;
}) {
const currentPreferences = (
await db
@@ -53,108 +78,21 @@ export async function updateUserMapModePreferences({
...mapModePreferences,
pool: mergedPool,
}),
})
.where("id", "=", userId)
.execute();
}
export async function updateTeamMapModePreferences({
teamId,
mapModePreferences,
}: {
teamId: number;
mapModePreferences: UserMapModePreferences;
}) {
const currentPreferences = (
await db
.selectFrom("AllTeam")
.select("mapModePreferences")
.where("id", "=", teamId)
.executeTakeFirstOrThrow()
).mapModePreferences;
const mergedPool = mergeExcludedModePreferences(
mapModePreferences.pool,
currentPreferences?.pool,
);
return db
.updateTable("AllTeam")
.set({
mapModePreferences: JSON.stringify({
...mapModePreferences,
pool: mergedPool,
}),
})
.where("id", "=", teamId)
.execute();
}
export function updateVoiceChat(args: {
userId: number;
vc: Tables["User"]["vc"];
languages: string[];
}) {
return db
.updateTable("User")
.set({
vc: args.vc,
languages: args.languages.length > 0 ? args.languages.join(",") : null,
})
.where("User.id", "=", args.userId)
.execute();
}
export function updateSendouQWeaponPool(args: {
userId: number;
weaponPool: WeaponPoolItem[];
}) {
return db
.updateTable("User")
.set({
qWeaponPool:
args.weaponPool.length > 0
vc,
languages: languages.length > 0 ? languages.join(",") : null,
weaponPool:
weaponPool.length > 0
? JSON.stringify(
args.weaponPool.map((wpn) => ({
weaponPool.map((wpn) => ({
weaponSplId: wpn.id,
isFavorite: Number(wpn.isFavorite),
})),
)
: null,
})
.where("User.id", "=", args.userId)
.execute();
}
export function updateNoScreen({
noScreen,
userId,
}: {
noScreen: number;
userId: number;
}) {
return db
.updateTable("User")
.set({
noScreen,
})
.where("User.id", "=", userId)
.execute();
}
export function updateNoSplatnet({
noSplatnet,
userId,
}: {
noSplatnet: number;
userId: number;
}) {
return db
.updateTable("User")
.set({
noSplatnet,
})
.where("User.id", "=", userId)
.where("id", "=", userId)
.execute();
}

View File

@@ -0,0 +1,3 @@
export const MATCH_PROFILE_WEAPON_POOL_MAX_SIZE = 4;
export const AMOUNT_OF_MAPS_IN_POOL_PER_MODE = 7;

View File

@@ -0,0 +1,10 @@
import { redirect } from "react-router";
import { MATCH_PROFILE_PAGE } from "~/utils/urls";
export const loader = () => {
throw redirect(MATCH_PROFILE_PAGE);
};
export default function MatchProfileRedirect() {
return null;
}

View File

@@ -144,7 +144,7 @@ function groupWithTeamAndMembers(
"User.vc",
"User.languages",
"User.noScreen",
"User.qWeaponPool as weapons",
"User.weaponPool as weapons",
"User.mapModePreferences",
"PlusTier.tier as plusTier",
"GroupMatchContinueVote.isContinuing",

View File

@@ -2,6 +2,10 @@ import * as R from "remeda";
import type { ParsedMemento, UserMapModePreferences } from "~/db/tables";
import * as MapList from "~/features/map-list-generator/core/MapList";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import {
BANNED_MAPS,
SENDOUQ_MAP_POOL,
} from "~/features/match-profile/banned-maps";
import * as Seasons from "~/features/mmr/core/Seasons";
import { userSkills } from "~/features/mmr/tiered.server";
import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server";
@@ -10,10 +14,6 @@ import type {
SQUncensoredGroup,
} from "~/features/sendouq/core/SendouQ.server";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import {
BANNED_MAPS,
SENDOUQ_MAP_POOL,
} from "~/features/sendouq-settings/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, ModeWithStage } from "~/modules/in-game-lists/types";
import type {

View File

@@ -1,59 +0,0 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
import { parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsActionSchema } from "../q-settings-schemas.server";
export const action = async ({ request }: { request: Request }) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: settingsActionSchema,
});
switch (data._action) {
case "UPDATE_MAP_MODE_PREFERENCES": {
if (typeof data.teamId === "number") {
const allTeams = await TeamRepository.findAllMemberOfByUserId(user.id);
const canManage = allTeams.some(
(t) => t.id === data.teamId && (t.isOwner || t.isManager),
);
if (!canManage) {
throw new Response(null, { status: 403 });
}
await QSettingsRepository.updateTeamMapModePreferences({
mapModePreferences: data.mapModePreferences,
teamId: data.teamId,
});
} else {
await QSettingsRepository.updateUserMapModePreferences({
mapModePreferences: data.mapModePreferences,
userId: user.id,
});
}
break;
}
case "UPDATE_VC": {
await QSettingsRepository.updateVoiceChat({
userId: user.id,
vc: data.vc,
languages: data.languages,
});
break;
}
case "UPDATE_SENDOUQ_WEAPON_POOL": {
await QSettingsRepository.updateSendouQWeaponPool({
userId: user.id,
weaponPool: data.weaponPool,
});
break;
}
default: {
assertUnreachable(data);
}
}
return { ok: true };
};

View File

@@ -1,15 +0,0 @@
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as TeamRepository from "~/features/team/TeamRepository.server";
export const loader = async () => {
const user = requireUser();
const allTeams = await TeamRepository.findAllMemberOfByUserId(user.id);
const manageableTeams = allTeams.filter((t) => t.isOwner || t.isManager);
return {
settings: await QSettingsRepository.settingsByUserId(user.id),
manageableTeams,
};
};

View File

@@ -1,3 +0,0 @@
export const SENDOUQ_WEAPON_POOL_MAX_SIZE = 4;
export const AMOUNT_OF_MAPS_IN_POOL_PER_MODE = 7;

View File

@@ -1,41 +0,0 @@
import { z } from "zod";
import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "./q-settings-constants";
import {
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "./q-settings-schemas";
const preference = z.enum(["AVOID", "PREFER"]).optional();
export const settingsActionSchema = z.union([
z.object({
_action: _action("UPDATE_MAP_MODE_PREFERENCES"),
teamId: z.preprocess(
(val) => (val === "" || val === undefined ? undefined : Number(val)),
id.optional(),
),
mapModePreferences: z.preprocess(
safeJSONParse,
z
.object({
modes: z.array(z.object({ mode: modeShort, preference })),
pool: z.array(
z.object({
stages: z.array(stageId).max(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
mode: modeShort,
}),
),
})
.refine(
(val) =>
val.pool.every((pool) => {
const mp = val.modes.find((m) => m.mode === pool.mode);
return mp?.preference !== "AVOID";
}),
"Can't have map pool for a mode that was avoided",
),
),
}),
updateVoiceChatSchema,
updateWeaponPoolSchema,
]);

View File

@@ -1,38 +0,0 @@
import { z } from "zod";
import {
checkboxGroup,
radioGroup,
stringConstant,
weaponPool,
} from "~/form/fields";
import { languagesUnified } from "~/modules/i18n/config";
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "./q-settings-constants";
export const updateWeaponPoolSchema = z.object({
_action: stringConstant("UPDATE_SENDOUQ_WEAPON_POOL"),
weaponPool: weaponPool({
label: "labels.weaponPool",
maxCount: SENDOUQ_WEAPON_POOL_MAX_SIZE,
}),
});
export const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({
label: () => lang.name,
value: lang.code,
}));
export const updateVoiceChatSchema = z.object({
_action: stringConstant("UPDATE_VC"),
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
languages: checkboxGroup({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
});

View File

@@ -1,36 +0,0 @@
.summary {
padding: var(--s-3);
border-radius: var(--radius-box);
background-color: var(--color-bg-high);
font-size: var(--font-lg);
font-weight: var(--weight-bold);
margin-block-end: var(--s-4);
position: relative;
& > div {
display: inline-flex;
}
& svg {
width: 24px;
color: var(--color-text-accent);
position: absolute;
right: 20px;
top: 14px;
}
}
.volumeSliderIcon {
width: 16px;
height: 16px;
}
/*
Necessary because default style adds padding, making the slider not go from 0 to 1 visually
Changing the default style would affect all other input elements, so we need to override it
*/
.volumeSliderInput {
padding-left: 0 !important;
padding-right: 0 !important;
border: 0 !important;
}

View File

@@ -1,538 +0,0 @@
import clsx from "clsx";
import { Map as MapIcon, Mic, Puzzle, Volume2 } from "lucide-react";
import * as React from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { useFetcher, useLoaderData } from "react-router";
import { Avatar } from "~/components/Avatar";
import { SendouSelect, SendouSelectItem } from "~/components/elements/Select";
import { ModeImage } from "~/components/Image";
import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import type { Preference, UserMapModePreferences } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import {
soundCodeToLocalStorageKey,
soundVolume,
} from "~/features/chat/chat-utils";
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
import { SendouForm } from "~/form/SendouForm";
import { useHydrated } from "~/hooks/useHydrated";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
navIconUrl,
SENDOUQ_PAGE,
SENDOUQ_SETTINGS_PAGE,
SETTINGS_PAGE,
soundPath,
} from "~/utils/urls";
import { action } from "../actions/q.settings.server";
import { BANNED_MAPS } from "../banned-maps";
import { ModeMapPoolPicker } from "../components/ModeMapPoolPicker";
import { PreferenceRadioGroup } from "../components/PreferenceRadioGroup";
import { loader } from "../loaders/q.settings.server";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "../q-settings-constants";
import {
updateVoiceChatSchema,
updateWeaponPoolSchema,
} from "../q-settings-schemas";
export { action, loader };
import styles from "./q.settings.module.css";
export const handle: SendouRouteHandle = {
i18n: ["q"],
breadcrumb: () => [
{
imgPath: navIconUrl("sendouq"),
href: SENDOUQ_PAGE,
type: "IMAGE",
},
{
imgPath: navIconUrl("settings"),
href: SENDOUQ_SETTINGS_PAGE,
type: "IMAGE",
},
],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Settings",
location: args.location,
});
};
export default function SendouQSettingsPage() {
return (
<Main className="stack sm">
<MapPicker />
<div className="half-width stack sm">
<WeaponPool />
<VoiceChat />
<Sounds />
<Misc />
</div>
</Main>
);
}
const PERSONAL_KEY = "personal";
type ManageableTeam = SerializeFrom<typeof loader>["manageableTeams"][number];
function preferencesFromRaw(
raw: UserMapModePreferences | null,
): UserMapModePreferences {
if (!raw) return { pool: [], modes: [] };
return {
modes: raw.modes,
pool: raw.pool.map((p) => ({
mode: p.mode,
stages: p.stages.filter((s) => !BANNED_MAPS[p.mode].includes(s)),
})),
};
}
function MapPicker() {
const { t } = useTranslation(["q", "common"]);
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const hasTeams = data.manageableTeams.length > 0;
const [selectedKey, setSelectedKey] = useState<string>(PERSONAL_KEY);
const selectedTeamId =
selectedKey === PERSONAL_KEY ? undefined : Number(selectedKey);
const preferencesForSelection = (key: string) => {
if (key === PERSONAL_KEY) {
return preferencesFromRaw(data.settings.mapModePreferences);
}
const team = data.manageableTeams.find((t) => String(t.id) === key);
return preferencesFromRaw(team?.mapModePreferences ?? null);
};
const [preferences, setPreferences] = useState<UserMapModePreferences>(() =>
preferencesForSelection(PERSONAL_KEY),
);
const handleSelectionChange = (key: string) => {
setSelectedKey(key);
setPreferences(preferencesForSelection(key));
};
const handleModePreferenceChange = ({
mode,
preference,
}: {
mode: ModeShort;
preference: Preference & "NEUTRAL";
}) => {
const newModePreferences = preferences.modes.filter(
(map) => map.mode !== mode,
);
if (preference !== "NEUTRAL") {
newModePreferences.push({
mode,
preference,
});
}
setPreferences({
...preferences,
modes: newModePreferences,
});
};
const poolsOk = () => {
for (const mode of modesShort) {
const mp = preferences.modes.find(
(preference) => preference.mode === mode,
);
if (mp?.preference === "AVOID") continue;
const pool = preferences.pool.find((p) => p.mode === mode);
if (pool && pool.stages.length > AMOUNT_OF_MAPS_IN_POOL_PER_MODE) {
return false;
}
}
return true;
};
const selectItems = [
{ id: PERSONAL_KEY, name: t("q:settings.maps.personal") },
...data.manageableTeams.map((team) => ({
id: String(team.id),
name: team.name,
})),
];
return (
<details>
<summary className={clsx(styles.summary, "half-width")}>
<div>
<span>{t("q:settings.maps.header")}</span> <MapIcon />
</div>
</summary>
<fetcher.Form method="post" className="mb-4">
<input
type="hidden"
name="mapModePreferences"
value={JSON.stringify({
...preferences,
pool: preferences.pool.filter((p) => {
const isAvoided =
preferences.modes.find((m) => m.mode === p.mode)?.preference ===
"AVOID";
return !isAvoided;
}),
})}
/>
{selectedTeamId ? (
<input type="hidden" name="teamId" value={selectedTeamId} />
) : null}
<div className="stack lg">
{hasTeams ? (
<div className="half-width">
<SendouSelect
value={selectedKey}
onChange={(key) => handleSelectionChange(String(key))}
aria-label={t("q:settings.maps.preferencesFor")}
items={selectItems}
bottomText={t("q:settings.maps.teamExplanation")}
>
{(item) => (
<SendouSelectItem
key={item.id}
id={item.id}
textValue={item.name}
>
<MapPickerSelectOption
item={item}
teams={data.manageableTeams}
/>
</SendouSelectItem>
)}
</SendouSelect>
</div>
) : null}
<div className="stack items-center">
{modesShort.map((modeShort) => {
const preference = preferences.modes.find(
(preference) => preference.mode === modeShort,
);
return (
<div key={modeShort} className="stack horizontal xs my-1">
<ModeImage mode={modeShort} width={32} />
<PreferenceRadioGroup
preference={preference?.preference}
onPreferenceChange={(preference) =>
handleModePreferenceChange({
mode: modeShort,
preference,
})
}
aria-label={`Select preference towards ${modeShort}`}
/>
</div>
);
})}
</div>
<div className="stack lg">
{modesShort.map((mode) => {
const mp = preferences.modes.find(
(preference) => preference.mode === mode,
);
if (mp?.preference === "AVOID") return null;
return (
<ModeMapPoolPicker
key={mode}
mode={mode}
amountToPick={AMOUNT_OF_MAPS_IN_POOL_PER_MODE}
pool={
preferences.pool.find((p) => p.mode === mode)?.stages ?? []
}
onChange={(stages) => {
const newPools = preferences.pool.filter(
(p) => p.mode !== mode,
);
newPools.push({ mode, stages });
setPreferences({
...preferences,
pool: newPools,
});
}}
/>
);
})}
</div>
</div>
<div className="mt-6">
{poolsOk() ? (
<SubmitButton
_action="UPDATE_MAP_MODE_PREFERENCES"
state={fetcher.state}
className="mx-auto"
>
{t("common:actions.save")}
</SubmitButton>
) : (
<div className="text-warning text-sm text-center font-bold">
{t("q:settings.mapPool.notOk", {
count: AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
})}
</div>
)}
</div>
</fetcher.Form>
</details>
);
}
function MapPickerSelectOption({
item,
teams,
}: {
item: { id: string; name: string };
teams: ManageableTeam[];
}) {
const user = useUser();
if (item.id === PERSONAL_KEY) {
return (
<div className="stack horizontal xs items-center">
<Avatar user={user} size="xxxs" />
{item.name}
</div>
);
}
const team = teams.find((t) => String(t.id) === item.id);
if (!team) return item.name;
return (
<div className="stack horizontal xs items-center">
<Avatar size="xxxs" url={team.logoUrl} identiconInput={team.name} />
{team.name}
</div>
);
}
function VoiceChat() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
return (
<details>
<summary className={styles.summary}>
<div>
<span>{t("q:settings.voiceChat.header")}</span> <Mic />
</div>
</summary>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateVoiceChatSchema}
defaultValues={{
vc: data.settings.vc,
languages: data.settings.languages ?? [],
}}
>
{({ FormField }) => (
<>
<FormField name="vc" />
<FormField name="languages" />
</>
)}
</SendouForm>
</div>
</details>
);
}
function WeaponPool() {
const { t } = useTranslation(["q"]);
const data = useLoaderData<typeof loader>();
const defaultWeaponPool = (data.settings.qWeaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
}));
return (
<details>
<summary className={styles.summary}>
<div>
<span>{t("q:settings.weaponPool.header")}</span> <Puzzle />
</div>
</summary>
<div className="mb-4">
<SendouForm
schema={updateWeaponPoolSchema}
defaultValues={{
weaponPool: defaultWeaponPool,
}}
>
{({ FormField }) => <FormField name="weaponPool" />}
</SendouForm>
</div>
</details>
);
}
function Sounds() {
const { t } = useTranslation(["q"]);
const isHydrated = useHydrated();
return (
<details>
<summary className={styles.summary}>
<div>
<span>{t("q:settings.sounds.header")}</span> <Volume2 />
</div>
</summary>
<div className="mb-4">
{isHydrated && <SoundCheckboxes />}
{isHydrated && <SoundSlider />}
</div>
</details>
);
}
function SoundCheckboxes() {
const { t } = useTranslation(["q"]);
const sounds = [
{
code: "sq_like",
name: t("q:settings.sounds.likeReceived"),
},
{
code: "sq_new-group",
name: t("q:settings.sounds.groupNewMember"),
},
{
code: "sq_match",
name: t("q:settings.sounds.matchStarted"),
},
{
code: "tournament_match",
name: t("q:settings.sounds.tournamentMatchStarted"),
},
];
// default to true
const currentValue = (code: string) =>
!localStorage.getItem(soundCodeToLocalStorageKey(code)) ||
localStorage.getItem(soundCodeToLocalStorageKey(code)) === "true";
const [soundValues, setSoundValues] = React.useState(
Object.fromEntries(
sounds.map((sound) => [sound.code, currentValue(sound.code)]),
),
);
// toggle in local storage
const toggleSound = (code: string) => {
localStorage.setItem(
soundCodeToLocalStorageKey(code),
String(!currentValue(code)),
);
setSoundValues((prev) => ({
...prev,
[code]: !prev[code],
}));
};
return (
<div className="stack sm ml-2-5">
{sounds.map((sound) => (
<div key={sound.code}>
<label className="stack horizontal xs items-center">
<input
type="checkbox"
checked={soundValues[sound.code]}
onChange={() => toggleSound(sound.code)}
/>
{sound.name}
</label>
</div>
))}
</div>
);
}
function SoundSlider() {
const [volume, setVolume] = useState(() => {
return soundVolume() || 100;
});
const changeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = Number.parseFloat(event.target.value);
setVolume(newVolume);
localStorage.setItem(
"settings__sound-volume",
String(Math.floor(newVolume)),
);
};
const playSound = () => {
const audio = new Audio(soundPath("sq_like"));
audio.volume = soundVolume() / 100;
void audio.play();
};
return (
<div className="stack horizontal xs items-center ml-2-5 mt-6">
<Volume2 className={styles.volumeSliderIcon} />
<input
className={styles.volumeSliderInput}
type="range"
value={volume}
onChange={changeVolume}
onTouchEnd={playSound}
onMouseUp={playSound}
/>
</div>
);
}
function Misc() {
const data = useLoaderData<typeof loader>();
const { t } = useTranslation(["q"]);
return (
<details>
<summary className={styles.summary}>
<div>{t("q:settings.misc.header")}</div>
</summary>
<div className="mb-4 ml-2-5">
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.settings.noScreen),
}}
action={SETTINGS_PAGE}
autoSubmit
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</details>
);
}

View File

@@ -61,7 +61,7 @@ export async function findCurrentGroups() {
vc: Tables["User"]["vc"];
role: Tables["GroupMember"]["role"];
note: Tables["GroupMember"]["note"];
weapons: Tables["User"]["qWeaponPool"];
weapons: Tables["User"]["weaponPool"];
plusTier: Tables["PlusTier"]["tier"] | null;
};
@@ -99,7 +99,7 @@ export async function findCurrentGroups() {
noScreen: eb.ref("User.noScreen"),
role: eb.ref("GroupMember.role"),
note: eb.ref("GroupMember.note"),
weapons: eb.ref("User.qWeaponPool"),
weapons: eb.ref("User.weaponPool"),
languages: eb.ref("User.languages"),
plusTier: eb.ref("PlusTier.tier"),
vc: eb.ref("User.vc"),

View File

@@ -9,9 +9,9 @@ import { metaTags } from "~/utils/remix";
import {
CALENDAR_PAGE,
FAQ_PAGE,
MATCH_PROFILE_PAGE,
navIconUrl,
SENDOUQ_RULES_PAGE,
SENDOUQ_SETTINGS_PAGE,
TIERS_PAGE,
} from "~/utils/urls";
import styles from "./q.info.module.css";
@@ -180,7 +180,7 @@ function BeforeJoining() {
<p>
Optional - if you don&apos;t have a preference then skip this step and
other players in the lobby get to choose. On the{" "}
<Link to={SENDOUQ_SETTINGS_PAGE}>settings page</Link> first select your
<Link to={MATCH_PROFILE_PAGE}>settings page</Link> first select your
preference of each of the five modes. Avoid means you&apos;d rather not
play the mode. Neutral means you don&apos;t have strong feelings either
way. Prefer means you like this mode over neutral modes. These choices

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import type { UserMapModePreferences } from "~/db/tables";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { stageIds } from "~/modules/in-game-lists/stage-ids";
import invariant from "~/utils/invariant";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";

View File

@@ -24,10 +24,10 @@ import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
MATCH_PROFILE_PAGE,
navIconUrl,
SENDOUQ_LOOKING_PAGE,
SENDOUQ_PAGE,
SENDOUQ_SETTINGS_PAGE,
SENDOUQ_STREAMS_PAGE,
} from "~/utils/urls";
import { action } from "../actions/q.looking.server";
@@ -173,7 +173,7 @@ function InfoText() {
>
<div className="stack sm horizontal">
<LinkButton
to={SENDOUQ_SETTINGS_PAGE}
to={MATCH_PROFILE_PAGE}
size="small"
variant="outlined"
className="stack horizontal xs"

View File

@@ -25,12 +25,12 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
import {
LEADERBOARDS_PAGE,
LOG_IN_URL,
MATCH_PROFILE_PAGE,
navIconUrl,
SENDOUQ_INFO_PAGE,
SENDOUQ_LOOKING_PREVIEW_PAGE,
SENDOUQ_PAGE,
SENDOUQ_RULES_PAGE,
SENDOUQ_SETTINGS_PAGE,
SENDOUQ_STREAMS_PAGE,
userSeasonsPage,
} from "~/utils/urls";
@@ -307,7 +307,7 @@ function QLinks() {
{user ? (
<QLink
navIcon="settings"
url={SENDOUQ_SETTINGS_PAGE}
url={MATCH_PROFILE_PAGE}
title={t("q:front.nav.settings.title")}
subText={t("q:front.nav.settings.description")}
/>

View File

@@ -1,18 +1,18 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isSupporter } from "~/modules/permissions/utils";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { settingsEditSchema } from "../settings-schemas";
import { settingsActionSchema } from "../settings-schemas.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const data = await parseRequestPayload({
request,
schema: settingsEditSchema,
schema: settingsActionSchema,
});
switch (data._action) {
@@ -46,20 +46,6 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "UPDATE_NO_SCREEN": {
await QSettingsRepository.updateNoScreen({
userId: user.id,
noScreen: Number(data.newValue),
});
break;
}
case "UPDATE_NO_SPLATNET": {
await QSettingsRepository.updateNoSplatnet({
userId: user.id,
noSplatnet: Number(data.newValue),
});
break;
}
case "UPDATE_CLOCK_FORMAT": {
await UserRepository.updatePreferences(user.id, {
clockFormat: data.newValue,
@@ -72,12 +58,22 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
break;
}
case "UPDATE_MATCH_PROFILE": {
await MatchProfileRepository.updateMatchProfile({
userId: user.id,
mapModePreferences: data.mapModePreferences,
vc: data.vc,
languages: data.languages,
weaponPool: data.weaponPool,
noScreen: Number(data.noScreen),
noSplatnet: Number(data.noSplatnet),
});
break;
}
default: {
assertUnreachable(data);
}
}
// TODO: removed temporarily, restore when we have better toasts
// (current problem is that when you update no screen from /q/settings, you get redirected to /settings)
// return successToast("Settings updated");
return null;
};

View File

@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { useUser } from "~/features/auth/core/user";
import { SelectFormField } from "~/form/fields/SelectFormField";
import { SendouForm } from "~/form/SendouForm";
import { languages } from "~/modules/i18n/config";
import { clockFormatSchema } from "../settings-schemas";
export function LocaleTab() {
const user = useUser();
return (
<div className="stack md">
<LanguageSelector />
{user ? (
<SendouForm
schema={clockFormatSchema}
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
autoSubmit
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
</div>
);
}
function LanguageSelector() {
const { t, i18n } = useTranslation(["common"]);
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const languageItems = languages.map((lang) => ({
value: lang.code,
label: lang.name,
}));
const handleLanguageChange = (newLang: string | null) => {
if (!newLang) return;
const next = new URLSearchParams(searchParams);
next.delete("lng");
next.append("lng", newLang);
navigate(`?${next.toString()}`);
};
return (
<SelectFormField
label={t("common:header.language")}
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}
/>
);
}

View File

@@ -0,0 +1,146 @@
import { useLoaderData } from "react-router";
import { ModeImage } from "~/components/Image";
import type { Preference, UserMapModePreferences } from "~/db/tables";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants";
import { SendouForm } from "~/form/SendouForm";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import type { loader } from "../loaders/settings.server";
import { updateMatchProfileSchema } from "../match-profile-schemas";
import { ModeMapPoolPicker } from "./ModeMapPoolPicker";
import { PreferenceRadioGroup } from "./PreferenceRadioGroup";
export function MatchProfileTab() {
const data = useLoaderData<typeof loader>();
const matchProfile = data.matchProfile;
if (!matchProfile) return null;
return (
<SendouForm
schema={updateMatchProfileSchema}
defaultValues={{
mapModePreferences: preferencesFromRaw(matchProfile.mapModePreferences),
weaponPool: (matchProfile.weaponPool ?? []).map((w) => ({
id: w.weaponSplId,
isFavorite: Boolean(w.isFavorite),
})),
vc: matchProfile.vc ?? "NO",
languages: matchProfile.languages ?? [],
noScreen: Boolean(matchProfile.noScreen),
noSplatnet: Boolean(matchProfile.noSplatnet),
}}
revalidateRoot
>
{({ FormField }) => (
<>
<FormField name="mapModePreferences">
{(props: {
value: unknown;
onChange: (value: UserMapModePreferences) => void;
}) => (
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
/>
)}
</FormField>
<FormField name="weaponPool" />
<FormField name="vc" />
<FormField name="languages" />
<FormField name="noSplatnet" />
<FormField name="noScreen" />
</>
)}
</SendouForm>
);
}
function preferencesFromRaw(
raw: UserMapModePreferences | null,
): UserMapModePreferences {
if (!raw) return { pool: [], modes: [] };
return {
modes: raw.modes,
pool: raw.pool.map((p) => ({
mode: p.mode,
stages: p.stages.filter((s) => !BANNED_MAPS[p.mode].includes(s)),
})),
};
}
function MapModePreferencesField({
value,
onChange,
}: {
value: UserMapModePreferences;
onChange: (value: UserMapModePreferences) => void;
}) {
const handleModePreferenceChange = ({
mode,
preference,
}: {
mode: ModeShort;
preference: Preference & "NEUTRAL";
}) => {
const newModePreferences = value.modes.filter((map) => map.mode !== mode);
if (preference !== "NEUTRAL") {
newModePreferences.push({ mode, preference });
}
const newPool =
preference === "AVOID"
? value.pool.filter((p) => p.mode !== mode)
: value.pool;
onChange({ modes: newModePreferences, pool: newPool });
};
const handlePoolChange = (mode: ModeShort, stages: StageId[]) => {
const filtered = value.pool.filter((p) => p.mode !== mode);
filtered.push({ mode, stages });
onChange({ ...value, pool: filtered });
};
return (
<div className="stack lg">
<div className="stack items-center">
{modesShort.map((modeShort) => {
const preference = value.modes.find(
(preference) => preference.mode === modeShort,
);
return (
<div key={modeShort} className="stack horizontal xs my-1">
<ModeImage mode={modeShort} width={32} />
<PreferenceRadioGroup
preference={preference?.preference}
onPreferenceChange={(preference) =>
handleModePreferenceChange({ mode: modeShort, preference })
}
aria-label={`Select preference towards ${modeShort}`}
/>
</div>
);
})}
</div>
<div className="stack lg">
{modesShort.map((mode) => {
const mp = value.modes.find((p) => p.mode === mode);
if (mp?.preference === "AVOID") return null;
return (
<ModeMapPoolPicker
key={mode}
mode={mode}
amountToPick={AMOUNT_OF_MAPS_IN_POOL_PER_MODE}
pool={value.pool.find((p) => p.mode === mode)?.stages ?? []}
onChange={(stages) => handlePoolChange(mode, stages)}
/>
);
})}
</div>
</div>
);
}

View File

@@ -4,11 +4,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import { ModeImage } from "~/components/Image";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { shortStageName, stageIds } from "~/modules/in-game-lists/stage-ids";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { nullFilledArray } from "~/utils/arrays";
import { stageImageUrl } from "~/utils/urls";
import { BANNED_MAPS } from "../banned-maps";
import styles from "./ModeMapPoolPicker.module.css";
export function ModeMapPoolPicker({

View File

@@ -14,7 +14,7 @@ export function PreferenceRadioGroup({
onPreferenceChange: (preference: Preference & "NEUTRAL") => void;
"aria-label": string;
}) {
const { t } = useTranslation(["q"]);
const { t } = useTranslation(["settings"]);
return (
<RadioGroup
@@ -40,7 +40,7 @@ export function PreferenceRadioGroup({
width={18}
alt="Avoid emoji"
/>
{t("q:settings.maps.avoid")}
{t("settings:matchProfile.maps.avoid")}
</span>
)}
</Radio>
@@ -58,7 +58,7 @@ export function PreferenceRadioGroup({
width={18}
alt="Neutral emoji"
/>
{t("q:settings.maps.neutral")}
{t("settings:matchProfile.maps.neutral")}
</span>
)}
</Radio>
@@ -76,7 +76,7 @@ export function PreferenceRadioGroup({
width={18}
alt="Prefer emoji"
/>
{t("q:settings.maps.prefer")}
{t("settings:matchProfile.maps.prefer")}
</span>
)}
</Radio>

View File

@@ -0,0 +1,150 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { useUser } from "~/features/auth/core/user";
import { SendouForm } from "~/form/SendouForm";
import {
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
} from "../settings-schemas";
export function PreferencesTab() {
const user = useUser();
if (!user) return null;
return (
<div className="stack md">
<PushNotificationsEnabler />
<Divider className="my-2" />
<div className="stack md">
<SendouForm
schema={disableBuildAbilitySortingSchema}
defaultValues={{
newValue: user.preferences.disableBuildAbilitySorting ?? false,
}}
autoSubmit
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={disallowScrimPickupsFromUntrustedSchema}
defaultValues={{
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
autoSubmit
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={spoilerFreeModeSchema}
defaultValues={{
newValue: user.preferences.spoilerFreeMode ?? false,
}}
autoSubmit
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</div>
);
}
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);
const [notificationsPermsGranted, setNotificationsPermsGranted] =
React.useState<NotificationPermission | "not-supported">("default");
React.useEffect(() => {
if (!("serviceWorker" in navigator)) {
setNotificationsPermsGranted("not-supported");
return;
}
if (!("PushManager" in window)) {
setNotificationsPermsGranted("not-supported");
return;
}
setNotificationsPermsGranted(Notification.permission);
}, []);
function askPermission() {
Notification.requestPermission().then((permission) => {
setNotificationsPermsGranted(permission);
if (permission === "granted") {
initServiceWorker();
}
});
}
async function initServiceWorker() {
const swRegistration = await navigator.serviceWorker.register("sw-2.js");
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
sendSubscriptionToServer(subscription);
} else {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
});
sendSubscriptionToServer(subscription);
}
}
function sendSubscriptionToServer(subscription: PushSubscription) {
fetch("/notifications/subscribe", {
method: "post",
body: JSON.stringify(subscription),
headers: { "content-type": "application/json" },
});
}
return (
<div>
<Label>{t("common:settings.notifications.title")}</Label>
{notificationsPermsGranted === "granted" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.disable")}
</SendouButton>
}
>
{t("common:settings.notifications.disableInfo")}
</SendouPopover>
) : notificationsPermsGranted === "not-supported" ||
notificationsPermsGranted === "denied" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.enable")}
</SendouButton>
}
>
{notificationsPermsGranted === "not-supported"
? t("common:settings.notifications.browserNotSupported")
: t("common:settings.notifications.permissionDenied")}
</SendouPopover>
) : (
<SendouButton size="small" variant="minimal" onPress={askPermission}>
{t("common:actions.enable")}
</SendouButton>
)}
<FormMessage type="info">
{t("common:settings.notifications.description")}
</FormMessage>
</div>
);
}

View File

@@ -0,0 +1,10 @@
.volumeSliderIcon {
width: 16px;
height: 16px;
}
.volumeSliderInput {
padding-left: 0 !important;
padding-right: 0 !important;
border: 0 !important;
}

View File

@@ -0,0 +1,106 @@
import { Volume2 } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
soundCodeToLocalStorageKey,
soundVolume,
} from "~/features/chat/chat-utils";
import { useHydrated } from "~/hooks/useHydrated";
import { soundPath } from "~/utils/urls";
import styles from "./SoundsTab.module.css";
export function SoundsTab() {
const isHydrated = useHydrated();
return (
<div className="stack md">
{isHydrated ? <SoundSlider /> : null}
{isHydrated ? <SoundCheckboxes /> : null}
</div>
);
}
function SoundCheckboxes() {
const { t } = useTranslation(["settings"]);
const sounds = [
{ code: "sq_like", name: t("settings:sounds.likeReceived") },
{ code: "sq_new-group", name: t("settings:sounds.groupNewMember") },
{ code: "sq_match", name: t("settings:sounds.matchStarted") },
{
code: "tournament_match",
name: t("settings:sounds.tournamentMatchStarted"),
},
];
const currentValue = (code: string) =>
!localStorage.getItem(soundCodeToLocalStorageKey(code)) ||
localStorage.getItem(soundCodeToLocalStorageKey(code)) === "true";
const [soundValues, setSoundValues] = React.useState(
Object.fromEntries(
sounds.map((sound) => [sound.code, currentValue(sound.code)]),
),
);
const toggleSound = (code: string) => {
localStorage.setItem(
soundCodeToLocalStorageKey(code),
String(!currentValue(code)),
);
setSoundValues((prev) => ({
...prev,
[code]: !prev[code],
}));
};
return (
<div className="stack sm">
{sounds.map((sound) => (
<div key={sound.code}>
<label className="stack horizontal xs items-center">
<input
type="checkbox"
checked={soundValues[sound.code]}
onChange={() => toggleSound(sound.code)}
/>
{sound.name}
</label>
</div>
))}
</div>
);
}
function SoundSlider() {
const [volume, setVolume] = React.useState(() => soundVolume() || 100);
const changeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = Number.parseFloat(event.target.value);
setVolume(newVolume);
localStorage.setItem(
"settings__sound-volume",
String(Math.floor(newVolume)),
);
};
const playSound = () => {
const audio = new Audio(soundPath("sq_like"));
audio.volume = soundVolume() / 100;
void audio.play();
};
return (
<div className="stack horizontal xs items-center">
<Volume2 className={styles.volumeSliderIcon} />
<input
className={styles.volumeSliderInput}
type="range"
value={volume}
onChange={changeVolume}
onTouchEnd={playSound}
onMouseUp={playSound}
/>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslation } from "react-i18next";
import { useFetcher, useMatches } from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { FormMessage } from "~/components/FormMessage";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { SelectFormField } from "~/form/fields/SelectFormField";
import { useHasRole } from "~/modules/permissions/hooks";
import type { RootLoaderData } from "~/root";
import type { ThemeInput } from "~/utils/oklch-gamut";
export function ThemeTab() {
const { t } = useTranslation(["common"]);
return (
<div className="stack md">
<ThemeSelector />
<CustomColorSelector />
<FormMessage type="info">{t("common:settings.themeInfo")}</FormMessage>
</div>
);
}
function ThemeSelector() {
const { t } = useTranslation(["common"]);
const { userTheme, setUserTheme } = useTheme();
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
(theme) => ({
value: theme,
label: t(`common:theme.${theme}`),
}),
);
const handleThemeChange = (newTheme: string | null) => {
if (!newTheme) return;
setUserTheme(newTheme as Theme);
};
return (
<SelectFormField
label={t("common:header.theme")}
items={themeItems}
value={userTheme ?? "auto"}
onChange={handleThemeChange}
/>
);
}
function CustomColorSelector() {
const [root] = useMatches();
const rootData = root.data as RootLoaderData | undefined;
const isSupporter = useHasRole("SUPPORTER");
const fetcher = useFetcher();
const handleSave = (themeInput: ThemeInput) => {
fetcher.submit(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: themeInput,
revalidateRoot: true,
} as unknown as Parameters<typeof fetcher.submit>[0],
{ method: "post", encType: "application/json" },
);
};
const handleReset = () => {
fetcher.submit(
{ _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true },
{ method: "post", encType: "application/json" },
);
};
return (
<CustomThemeSelector
isPersonalTheme
initialTheme={rootData?.customTheme}
isSupporter={isSupporter}
onSave={handleSave}
onReset={handleReset}
fetcherState={fetcher.state}
/>
);
}

View File

@@ -1,15 +1,15 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
export const loader = async () => {
export const loader = async (_args: LoaderFunctionArgs) => {
const user = getUser();
return {
noScreen: user
? await UserRepository.anyUserPrefersNoScreen([user.id])
: null,
noSplatnet: user
? await UserRepository.anyUserPrefersNoSplatnet([user.id])
: null,
};
if (!user) {
return { matchProfile: null };
}
const matchProfile = await MatchProfileRepository.settingsByUserId(user.id);
return { matchProfile };
};

View File

@@ -0,0 +1,74 @@
import { z } from "zod";
import type { UserMapModePreferences } from "~/db/tables";
import {
checkboxGroup,
customField,
radioGroup,
stringConstant,
toggle,
weaponPool,
} from "~/form/fields";
import { languagesUnified } from "~/modules/i18n/config";
import { modeShort, stageId } from "~/utils/zod";
import {
AMOUNT_OF_MAPS_IN_POOL_PER_MODE,
MATCH_PROFILE_WEAPON_POOL_MAX_SIZE,
} from "../match-profile/match-profile-constants";
export const LANGUAGE_OPTIONS = languagesUnified.map((lang) => ({
label: () => lang.name,
value: lang.code,
}));
const preferenceSchema = z.enum(["AVOID", "PREFER"]).optional();
const mapModePreferencesValueSchema = z
.object({
modes: z.array(z.object({ mode: modeShort, preference: preferenceSchema })),
pool: z.array(
z.object({
stages: z.array(stageId).max(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
mode: modeShort,
}),
),
})
.refine(
(val) =>
val.pool.every((pool) => {
const mp = val.modes.find((m) => m.mode === pool.mode);
return mp?.preference !== "AVOID";
}),
"Can't have map pool for a mode that was avoided",
);
export const updateMatchProfileSchema = z.object({
_action: stringConstant("UPDATE_MATCH_PROFILE"),
mapModePreferences: customField(
{ initialValue: { modes: [], pool: [] } satisfies UserMapModePreferences },
mapModePreferencesValueSchema,
),
weaponPool: weaponPool({
label: "labels.weaponPool",
maxCount: MATCH_PROFILE_WEAPON_POOL_MAX_SIZE,
}),
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
languages: checkboxGroup({
label: "labels.languages",
items: LANGUAGE_OPTIONS,
}),
noSplatnet: toggle({
label: "labels.noSplatnet",
bottomText: "bottomTexts.noScreen",
}),
noScreen: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",
}),
});

View File

@@ -1,4 +0,0 @@
.divider {
margin-top: var(--s-4);
margin-bottom: calc(var(--s-2) * -1);
}

View File

@@ -1,48 +1,41 @@
import { LogOut } from "lucide-react";
import * as React from "react";
import {
Globe,
LogOut,
Map as MapIcon,
Palette,
SlidersHorizontal,
Volume2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import {
useFetcher,
useLoaderData,
useMatches,
useNavigate,
useSearchParams,
} from "react-router";
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
import { Divider } from "~/components/Divider";
import { FormMessage } from "~/components/FormMessage";
import { Label } from "~/components/Label";
import { useSearchParams } from "react-router";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { SelectFormField } from "~/form/fields/SelectFormField";
import { SendouForm } from "~/form/SendouForm";
import { languages } from "~/modules/i18n/config";
import { useHasRole } from "~/modules/permissions/hooks";
import type { RootLoaderData } from "~/root";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { LOG_OUT_URL, navIconUrl, SETTINGS_PAGE } from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { action } from "../actions/settings.server";
import { loader } from "../loaders/settings.server";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
updateNoScreenSchema,
updateNoSplatnetSchema,
} from "../settings-schemas";
import styles from "./settings.module.css";
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "../../../components/elements/Tabs";
import { action } from "../actions/settings.server";
import { LocaleTab } from "../components/LocaleTab";
import { MatchProfileTab } from "../components/MatchProfileTab";
import { PreferencesTab } from "../components/PreferencesTab";
import { SoundsTab } from "../components/SoundsTab";
import { ThemeTab } from "../components/ThemeTab";
import { loader } from "../loaders/settings.server";
import type { SettingsTabSlug } from "../settings-constants";
import { defaultTab, resolveActiveTab } from "../settings-utils";
import "./settings.global.css";
import type { ThemeInput } from "~/utils/oklch-gamut";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["settings"],
breadcrumb: () => ({
imgPath: navIconUrl("settings"),
href: SETTINGS_PAGE,
@@ -50,15 +43,38 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Settings",
location: args.location,
});
};
export default function SettingsPage() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "settings"]);
const [searchParams, setSearchParams] = useSearchParams();
const isLoggedIn = Boolean(user);
const activeTab = resolveActiveTab(searchParams.get("tab"), isLoggedIn);
const handleSelectionChange = (key: React.Key) => {
const slug = key as SettingsTabSlug;
const next = new URLSearchParams(searchParams);
if (slug === defaultTab(isLoggedIn)) {
next.delete("tab");
} else {
next.set("tab", slug);
}
setSearchParams(next, {
defaultShouldRevalidate: false,
});
};
return (
<Main halfWidth>
<Main>
<div className="stack md">
<div className="stack horizontal justify-between">
<div className="stack horizontal justify-between items-center">
<h2 className="text-lg">{t("common:pages.settings")}</h2>
{user ? (
<form method="post" action={LOG_OUT_URL}>
@@ -73,289 +89,58 @@ export default function SettingsPage() {
</form>
) : null}
</div>
<Divider className={styles.divider} smallText>
{t("common:settings.locales")}
</Divider>
<LanguageSelector />
{user ? (
<SendouForm
schema={clockFormatSchema}
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
{user ? (
<>
<Divider className={styles.divider} smallText>
{t("common:settings.preferences")}
</Divider>
<PushNotificationsEnabler />
<div className="mt-6 stack md">
<SendouForm
schema={disableBuildAbilitySortingSchema}
defaultValues={{
newValue:
user.preferences.disableBuildAbilitySorting ?? false,
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={disallowScrimPickupsFromUntrustedSchema}
defaultValues={{
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={spoilerFreeModeSchema}
defaultValues={{
newValue: user.preferences.spoilerFreeMode ?? false,
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={updateNoSplatnetSchema}
defaultValues={{
newValue: Boolean(data.noSplatnet),
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={updateNoScreenSchema}
defaultValues={{
newValue: Boolean(data.noScreen),
}}
autoSubmit
revalidateRoot
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</>
) : null}
<Divider className={styles.divider} smallText>
{t("common:settings.theme")}
</Divider>
<ThemeSelector />
<CustomColorSelector />
<FormMessage type="info">{t("common:settings.themeInfo")}</FormMessage>
<SendouTabs
orientation="vertical"
horizontalBelow={720}
selectedKey={activeTab}
onSelectionChange={handleSelectionChange}
>
<SendouTabList aria-label={t("common:pages.settings")}>
{user ? (
<SendouTab id="match-profile" icon={<MapIcon />}>
{t("settings:tabs.matchProfile")}
</SendouTab>
) : null}
{user ? (
<SendouTab id="preferences" icon={<SlidersHorizontal />}>
{t("settings:tabs.preferences")}
</SendouTab>
) : null}
<SendouTab id="locale" icon={<Globe />}>
{t("settings:tabs.locale")}
</SendouTab>
<SendouTab id="theme" icon={<Palette />}>
{t("settings:tabs.theme")}
</SendouTab>
{user ? (
<SendouTab id="sounds" icon={<Volume2 />}>
{t("settings:tabs.sounds")}
</SendouTab>
) : null}
</SendouTabList>
{user ? (
<SendouTabPanel id="preferences">
<PreferencesTab />
</SendouTabPanel>
) : null}
{user ? (
<SendouTabPanel id="match-profile">
<MatchProfileTab />
</SendouTabPanel>
) : null}
<SendouTabPanel id="locale">
<LocaleTab />
</SendouTabPanel>
<SendouTabPanel id="theme">
<ThemeTab />
</SendouTabPanel>
{user ? (
<SendouTabPanel id="sounds">
<SoundsTab />
</SendouTabPanel>
) : null}
</SendouTabs>
</div>
</Main>
);
}
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Settings",
location: args.location,
});
};
function LanguageSelector() {
const { t } = useTranslation(["common"]);
const { i18n } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const languageItems = languages.map((lang) => ({
value: lang.code,
label: lang.name,
}));
const handleLanguageChange = (newLang: string | null) => {
if (!newLang) return;
navigate(`?${addUniqueParam(searchParams, "lng", newLang).toString()}`);
};
return (
<SelectFormField
label={t("common:header.language")}
bottomText="forms:bottomTexts.languageClockTimeNote"
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}
/>
);
}
function addUniqueParam(
oldParams: URLSearchParams,
name: string,
value: string,
): URLSearchParams {
const paramsCopy = new URLSearchParams(oldParams);
paramsCopy.delete(name);
paramsCopy.append(name, value);
return paramsCopy;
}
function ThemeSelector() {
const { t } = useTranslation(["common"]);
const { userTheme, setUserTheme } = useTheme();
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
(theme) => ({
value: theme,
label: t(`common:theme.${theme}`),
}),
);
const handleThemeChange = (newTheme: string | null) => {
if (!newTheme) return;
setUserTheme(newTheme as Theme);
};
return (
<SelectFormField
label={t("common:header.theme")}
items={themeItems}
value={userTheme ?? "auto"}
onChange={handleThemeChange}
/>
);
}
function CustomColorSelector() {
const [root] = useMatches();
const rootData = root.data as RootLoaderData | undefined;
const isSupporter = useHasRole("SUPPORTER");
const fetcher = useFetcher();
const handleSave = (themeInput: ThemeInput) => {
fetcher.submit(
{
_action: "UPDATE_CUSTOM_THEME",
newValue: themeInput,
revalidateRoot: true,
} as unknown as Parameters<typeof fetcher.submit>[0],
{ method: "post", encType: "application/json" },
);
};
const handleReset = () => {
fetcher.submit(
{ _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true },
{ method: "post", encType: "application/json" },
);
};
return (
<CustomThemeSelector
isPersonalTheme
initialTheme={rootData?.customTheme}
isSupporter={isSupporter}
onSave={handleSave}
onReset={handleReset}
fetcherState={fetcher.state}
/>
);
}
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);
const [notificationsPermsGranted, setNotificationsPermsGranted] =
React.useState<NotificationPermission | "not-supported">("default");
React.useEffect(() => {
if (!("serviceWorker" in navigator)) {
// Service Worker isn't supported on this browser, disable or hide UI.
setNotificationsPermsGranted("not-supported");
return;
}
if (!("PushManager" in window)) {
// Push isn't supported on this browser, disable or hide UI.
setNotificationsPermsGranted("not-supported");
return;
}
setNotificationsPermsGranted(Notification.permission);
}, []);
function askPermission() {
Notification.requestPermission().then((permission) => {
setNotificationsPermsGranted(permission);
if (permission === "granted") {
initServiceWorker();
}
});
}
async function initServiceWorker() {
const swRegistration = await navigator.serviceWorker.register("sw-2.js");
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
sendSubscriptionToServer(subscription);
} else {
const subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
});
sendSubscriptionToServer(subscription);
}
}
function sendSubscriptionToServer(subscription: PushSubscription) {
fetch("/notifications/subscribe", {
method: "post",
body: JSON.stringify(subscription),
headers: { "content-type": "application/json" },
});
}
return (
<div>
<Label>{t("common:settings.notifications.title")}</Label>
{notificationsPermsGranted === "granted" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.disable")}
</SendouButton>
}
>
{t("common:settings.notifications.disableInfo")}
</SendouPopover>
) : notificationsPermsGranted === "not-supported" ||
notificationsPermsGranted === "denied" ? (
<SendouPopover
trigger={
<SendouButton size="small" variant="minimal">
{t("common:actions.enable")}
</SendouButton>
}
>
{notificationsPermsGranted === "not-supported"
? t("common:settings.notifications.browserNotSupported")
: t("common:settings.notifications.permissionDenied")}
</SendouPopover>
) : (
<SendouButton size="small" variant="minimal" onPress={askPermission}>
{t("common:actions.enable")}
</SendouButton>
)}
<FormMessage type="info">
{t("common:settings.notifications.description")}
</FormMessage>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export const SETTINGS_TAB_SLUGS = [
"preferences",
"match-profile",
"locale",
"theme",
"sounds",
] as const;
export type SettingsTabSlug = (typeof SETTINGS_TAB_SLUGS)[number];

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
import { updateMatchProfileSchema } from "./match-profile-schemas";
import { settingsEditSchema } from "./settings-schemas";
export const settingsActionSchema = z.union([
settingsEditSchema,
updateMatchProfileSchema,
]);

View File

@@ -43,22 +43,6 @@ export const spoilerFreeModeSchema = z.object({
}),
});
export const updateNoScreenSchema = z.object({
_action: stringConstant("UPDATE_NO_SCREEN"),
newValue: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",
}),
});
export const updateNoSplatnetSchema = z.object({
_action: stringConstant("UPDATE_NO_SPLATNET"),
newValue: toggle({
label: "labels.noSplatnet",
bottomText: "bottomTexts.noScreen",
}),
});
const weaponReportDefaultOpenSchema = z.object({
_action: stringConstant("UPDATE_WEAPON_REPORT_DEFAULT_OPEN"),
newValue: z.boolean(),
@@ -69,8 +53,6 @@ export const settingsEditSchema = z.union([
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
updateNoScreenSchema,
updateNoSplatnetSchema,
clockFormatSchema,
weaponReportDefaultOpenSchema,
]);

View File

@@ -0,0 +1,19 @@
import { SETTINGS_TAB_SLUGS, type SettingsTabSlug } from "./settings-constants";
const PUBLIC_TABS = new Set<SettingsTabSlug>(["locale", "theme"]);
export function defaultTab(isLoggedIn: boolean): SettingsTabSlug {
return isLoggedIn ? "match-profile" : "theme";
}
export function resolveActiveTab(
raw: string | null,
isLoggedIn: boolean,
): SettingsTabSlug {
if (raw && (SETTINGS_TAB_SLUGS as readonly string[]).includes(raw)) {
const slug = raw as SettingsTabSlug;
if (!isLoggedIn && !PUBLIC_TABS.has(slug)) return defaultTab(false);
return slug;
}
return defaultTab(isLoggedIn);
}

View File

@@ -66,7 +66,7 @@ type TournamentLFGMemberObject = {
pronouns: Tables["User"]["pronouns"];
role: Tables["TournamentTeamMember"]["role"];
isStayAsSub: Tables["TournamentTeamMember"]["isStayAsSub"];
weapons: Tables["User"]["qWeaponPool"];
weapons: Tables["User"]["weaponPool"];
plusTier: Tables["PlusTier"]["tier"] | null;
};
@@ -106,7 +106,7 @@ export async function findLookingTeamsByTournamentId(tournamentId: number) {
pronouns: eb.ref("User.pronouns"),
role: eb.ref("TournamentTeamMember.role"),
isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"),
weapons: eb.ref("User.qWeaponPool"),
weapons: eb.ref("User.weaponPool"),
plusTier: eb.ref("PlusTier.tier"),
}),
])
@@ -145,7 +145,7 @@ export async function findSubGroups(tournamentId: number) {
pronouns: eb.ref("User.pronouns"),
role: eb.ref("TournamentTeamMember.role"),
isStayAsSub: eb.ref("TournamentTeamMember.isStayAsSub"),
weapons: eb.ref("User.qWeaponPool"),
weapons: eb.ref("User.weaponPool"),
plusTier: eb.ref("PlusTier.tier"),
}),
])

View File

@@ -43,7 +43,7 @@ import TimePopover from "~/components/TimePopover";
import { useUser } from "~/features/auth/core/user";
import { imgTypeToDimensions } from "~/features/img-upload/upload-constants";
import { MapPool } from "~/features/map-list-generator/core/map-pool";
import { ModeMapPoolPicker } from "~/features/sendouq-settings/components/ModeMapPoolPicker";
import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
import { useAutoRerender } from "~/hooks/useAutoRerender";

View File

@@ -11,8 +11,8 @@ import type {
} from "../../db/tables";
import { assertUnreachable } from "../../utils/types";
import { MapPool } from "../map-list-generator/core/map-pool";
import { BANNED_MAPS } from "../match-profile/banned-maps";
import * as Seasons from "../mmr/core/Seasons";
import { BANNED_MAPS } from "../sendouq-settings/banned-maps";
import type { ParsedBracket } from "../tournament-bracket/core/Progression";
import * as Progression from "../tournament-bracket/core/Progression";
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";

View File

@@ -1,10 +1,15 @@
.form {
display: flex;
flex-direction: column;
gap: var(--s-4);
gap: var(--s-6);
width: 100%;
max-width: 24rem;
margin: 0 auto;
&.fullWidth {
margin: 0;
max-width: none;
}
}
.title {

View File

@@ -1,3 +1,4 @@
import clsx from "clsx";
import * as React from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -63,6 +64,12 @@ type BaseFormProps<T extends z.ZodRawShape> = {
autoApply?: boolean;
revalidateRoot?: boolean;
className?: string;
/**
* When true, opts out of the default centered, max-width layout so the form
* expands to fill its parent container. Use when embedding a form inside a
* layout that already controls width/alignment.
*/
fullWidth?: boolean;
onApply?: (values: z.infer<z.ZodObject<T>>) => void;
secondarySubmit?: React.ReactNode;
};
@@ -89,6 +96,7 @@ export function SendouForm<T extends z.ZodRawShape>({
autoApply,
revalidateRoot,
className,
fullWidth,
onApply,
secondarySubmit,
}: SendouFormProps<T>) {
@@ -388,15 +396,18 @@ export function SendouForm<T extends z.ZodRawShape>({
</>
);
const resolvedClassName =
className ?? clsx(styles.form, { [styles.fullWidth]: fullWidth });
return (
<FormContext.Provider value={contextValue as FormContextValue}>
{autoApply && onApply ? (
<div className={className ?? styles.form}>{formContent}</div>
<div className={resolvedClassName}>{formContent}</div>
) : (
<form
method={method}
action={action}
className={className ?? styles.form}
className={resolvedClassName}
onSubmit={handleSubmit}
>
{formContent}

View File

@@ -15,6 +15,7 @@ import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import q from "../../../locales/en/q.json";
import scrims from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
import tierListMaker from "../../../locales/en/tier-list-maker.json";
import tournament from "../../../locales/en/tournament.json";
@@ -41,6 +42,7 @@ export const resources = {
org,
q,
scrims,
settings,
team,
"tier-list-maker": tierListMaker,
tournament,

View File

@@ -16,6 +16,7 @@ import lfgDa from "../../../locales/da/lfg.json";
import orgDa from "../../../locales/da/org.json";
import qDa from "../../../locales/da/q.json";
import scrimsDa from "../../../locales/da/scrims.json";
import settingsDa from "../../../locales/da/settings.json";
import teamDa from "../../../locales/da/team.json";
import tierListMakerDa from "../../../locales/da/tier-list-maker.json";
import tournamentDa from "../../../locales/da/tournament.json";
@@ -40,6 +41,7 @@ import lfgDe from "../../../locales/de/lfg.json";
import orgDe from "../../../locales/de/org.json";
import qDe from "../../../locales/de/q.json";
import scrimsDe from "../../../locales/de/scrims.json";
import settingsDe from "../../../locales/de/settings.json";
import teamDe from "../../../locales/de/team.json";
import tierListMakerDe from "../../../locales/de/tier-list-maker.json";
import tournamentDe from "../../../locales/de/tournament.json";
@@ -64,6 +66,7 @@ import lfg from "../../../locales/en/lfg.json";
import org from "../../../locales/en/org.json";
import q from "../../../locales/en/q.json";
import scrimsEn from "../../../locales/en/scrims.json";
import settings from "../../../locales/en/settings.json";
import team from "../../../locales/en/team.json";
import tierListMaker from "../../../locales/en/tier-list-maker.json";
import tournament from "../../../locales/en/tournament.json";
@@ -88,6 +91,7 @@ import lfgEsEs from "../../../locales/es-ES/lfg.json";
import orgEsEs from "../../../locales/es-ES/org.json";
import qEsEs from "../../../locales/es-ES/q.json";
import scrimsEsEs from "../../../locales/es-ES/scrims.json";
import settingsEsEs from "../../../locales/es-ES/settings.json";
import teamEsEs from "../../../locales/es-ES/team.json";
import tierListMakerEsEs from "../../../locales/es-ES/tier-list-maker.json";
import tournamentEsEs from "../../../locales/es-ES/tournament.json";
@@ -112,6 +116,7 @@ import lfgEsUs from "../../../locales/es-US/lfg.json";
import orgEsUs from "../../../locales/es-US/org.json";
import qEsUs from "../../../locales/es-US/q.json";
import scrimsEsUs from "../../../locales/es-US/scrims.json";
import settingsEsUs from "../../../locales/es-US/settings.json";
import teamEsUs from "../../../locales/es-US/team.json";
import tierListMakerEsUs from "../../../locales/es-US/tier-list-maker.json";
import tournamentEsUs from "../../../locales/es-US/tournament.json";
@@ -136,6 +141,7 @@ import lfgFrCa from "../../../locales/fr-CA/lfg.json";
import orgFrCa from "../../../locales/fr-CA/org.json";
import qFrCa from "../../../locales/fr-CA/q.json";
import scrimsFrCa from "../../../locales/fr-CA/scrims.json";
import settingsFrCa from "../../../locales/fr-CA/settings.json";
import teamFrCa from "../../../locales/fr-CA/team.json";
import tierListMakerFrCa from "../../../locales/fr-CA/tier-list-maker.json";
import tournamentFrCa from "../../../locales/fr-CA/tournament.json";
@@ -160,6 +166,7 @@ import lfgFrEu from "../../../locales/fr-EU/lfg.json";
import orgFrEu from "../../../locales/fr-EU/org.json";
import qFrEu from "../../../locales/fr-EU/q.json";
import scrimsFrEu from "../../../locales/fr-EU/scrims.json";
import settingsFrEu from "../../../locales/fr-EU/settings.json";
import teamFrEu from "../../../locales/fr-EU/team.json";
import tierListMakerFrEu from "../../../locales/fr-EU/tier-list-maker.json";
import tournamentFrEu from "../../../locales/fr-EU/tournament.json";
@@ -184,6 +191,7 @@ import lfgHe from "../../../locales/he/lfg.json";
import orgHe from "../../../locales/he/org.json";
import qHe from "../../../locales/he/q.json";
import scrimsHe from "../../../locales/he/scrims.json";
import settingsHe from "../../../locales/he/settings.json";
import teamHe from "../../../locales/he/team.json";
import tierListMakerHe from "../../../locales/he/tier-list-maker.json";
import tournamentHe from "../../../locales/he/tournament.json";
@@ -208,6 +216,7 @@ import lfgIt from "../../../locales/it/lfg.json";
import orgIt from "../../../locales/it/org.json";
import qIt from "../../../locales/it/q.json";
import scrimsIt from "../../../locales/it/scrims.json";
import settingsIt from "../../../locales/it/settings.json";
import teamIt from "../../../locales/it/team.json";
import tierListMakerIt from "../../../locales/it/tier-list-maker.json";
import tournamentIt from "../../../locales/it/tournament.json";
@@ -232,6 +241,7 @@ import lfgJa from "../../../locales/ja/lfg.json";
import orgJa from "../../../locales/ja/org.json";
import qJa from "../../../locales/ja/q.json";
import scrimsJa from "../../../locales/ja/scrims.json";
import settingsJa from "../../../locales/ja/settings.json";
import teamJa from "../../../locales/ja/team.json";
import tierListMakerJa from "../../../locales/ja/tier-list-maker.json";
import tournamentJa from "../../../locales/ja/tournament.json";
@@ -256,6 +266,7 @@ import lfgKo from "../../../locales/ko/lfg.json";
import orgKo from "../../../locales/ko/org.json";
import qKo from "../../../locales/ko/q.json";
import scrimsKo from "../../../locales/ko/scrims.json";
import settingsKo from "../../../locales/ko/settings.json";
import teamKo from "../../../locales/ko/team.json";
import tierListMakerKo from "../../../locales/ko/tier-list-maker.json";
import tournamentKo from "../../../locales/ko/tournament.json";
@@ -280,6 +291,7 @@ import lfgNl from "../../../locales/nl/lfg.json";
import orgNl from "../../../locales/nl/org.json";
import qNl from "../../../locales/nl/q.json";
import scrimsNl from "../../../locales/nl/scrims.json";
import settingsNl from "../../../locales/nl/settings.json";
import teamNl from "../../../locales/nl/team.json";
import tierListMakerNl from "../../../locales/nl/tier-list-maker.json";
import tournamentNl from "../../../locales/nl/tournament.json";
@@ -304,6 +316,7 @@ import lfgPl from "../../../locales/pl/lfg.json";
import orgPl from "../../../locales/pl/org.json";
import qPl from "../../../locales/pl/q.json";
import scrimsPl from "../../../locales/pl/scrims.json";
import settingsPl from "../../../locales/pl/settings.json";
import teamPl from "../../../locales/pl/team.json";
import tierListMakerPl from "../../../locales/pl/tier-list-maker.json";
import tournamentPl from "../../../locales/pl/tournament.json";
@@ -328,6 +341,7 @@ import lfgPtBr from "../../../locales/pt-BR/lfg.json";
import orgPtBr from "../../../locales/pt-BR/org.json";
import qPtBr from "../../../locales/pt-BR/q.json";
import scrimsPtBr from "../../../locales/pt-BR/scrims.json";
import settingsPtBr from "../../../locales/pt-BR/settings.json";
import teamPtBr from "../../../locales/pt-BR/team.json";
import tierListMakerPtBr from "../../../locales/pt-BR/tier-list-maker.json";
import tournamentPtBr from "../../../locales/pt-BR/tournament.json";
@@ -352,6 +366,7 @@ import lfgRu from "../../../locales/ru/lfg.json";
import orgRu from "../../../locales/ru/org.json";
import qRu from "../../../locales/ru/q.json";
import scrimsRu from "../../../locales/ru/scrims.json";
import settingsRu from "../../../locales/ru/settings.json";
import teamRu from "../../../locales/ru/team.json";
import tierListMakerRu from "../../../locales/ru/tier-list-maker.json";
import tournamentRu from "../../../locales/ru/tournament.json";
@@ -376,6 +391,7 @@ import lfgZh from "../../../locales/zh/lfg.json";
import orgZh from "../../../locales/zh/org.json";
import qZh from "../../../locales/zh/q.json";
import scrimsZh from "../../../locales/zh/scrims.json";
import settingsZh from "../../../locales/zh/settings.json";
import teamZh from "../../../locales/zh/team.json";
import tierListMakerZh from "../../../locales/zh/tier-list-maker.json";
import tournamentZh from "../../../locales/zh/tournament.json";
@@ -391,6 +407,7 @@ export const resources = {
friends: friendsEsUs,
weapons: weaponsEsUs,
scrims: scrimsEsUs,
settings: settingsEsUs,
common: commonEsUs,
"game-badges": gameBadgesEsUs,
"game-misc": gameMiscEsUs,
@@ -417,6 +434,7 @@ export const resources = {
friends: friends,
weapons: weapons,
scrims: scrimsEn,
settings: settings,
common: common,
"game-badges": gameBadges,
"game-misc": gameMisc,
@@ -443,6 +461,7 @@ export const resources = {
friends: friendsKo,
weapons: weaponsKo,
scrims: scrimsKo,
settings: settingsKo,
common: commonKo,
"game-badges": gameBadgesKo,
"game-misc": gameMiscKo,
@@ -469,6 +488,7 @@ export const resources = {
friends: friendsDe,
weapons: weaponsDe,
scrims: scrimsDe,
settings: settingsDe,
common: commonDe,
"game-badges": gameBadgesDe,
"game-misc": gameMiscDe,
@@ -495,6 +515,7 @@ export const resources = {
friends: friendsNl,
weapons: weaponsNl,
scrims: scrimsNl,
settings: settingsNl,
common: commonNl,
"game-badges": gameBadgesNl,
"game-misc": gameMiscNl,
@@ -521,6 +542,7 @@ export const resources = {
friends: friendsPtBr,
weapons: weaponsPtBr,
scrims: scrimsPtBr,
settings: settingsPtBr,
common: commonPtBr,
"game-badges": gameBadgesPtBr,
"game-misc": gameMiscPtBr,
@@ -547,6 +569,7 @@ export const resources = {
friends: friendsZh,
weapons: weaponsZh,
scrims: scrimsZh,
settings: settingsZh,
common: commonZh,
"game-badges": gameBadgesZh,
"game-misc": gameMiscZh,
@@ -573,6 +596,7 @@ export const resources = {
friends: friendsFrCa,
weapons: weaponsFrCa,
scrims: scrimsFrCa,
settings: settingsFrCa,
common: commonFrCa,
"game-badges": gameBadgesFrCa,
"game-misc": gameMiscFrCa,
@@ -599,6 +623,7 @@ export const resources = {
friends: friendsRu,
weapons: weaponsRu,
scrims: scrimsRu,
settings: settingsRu,
common: commonRu,
"game-badges": gameBadgesRu,
"game-misc": gameMiscRu,
@@ -625,6 +650,7 @@ export const resources = {
friends: friendsIt,
weapons: weaponsIt,
scrims: scrimsIt,
settings: settingsIt,
common: commonIt,
"game-badges": gameBadgesIt,
"game-misc": gameMiscIt,
@@ -651,6 +677,7 @@ export const resources = {
friends: friendsJa,
weapons: weaponsJa,
scrims: scrimsJa,
settings: settingsJa,
common: commonJa,
"game-badges": gameBadgesJa,
"game-misc": gameMiscJa,
@@ -677,6 +704,7 @@ export const resources = {
friends: friendsDa,
weapons: weaponsDa,
scrims: scrimsDa,
settings: settingsDa,
common: commonDa,
"game-badges": gameBadgesDa,
"game-misc": gameMiscDa,
@@ -703,6 +731,7 @@ export const resources = {
friends: friendsEsEs,
weapons: weaponsEsEs,
scrims: scrimsEsEs,
settings: settingsEsEs,
common: commonEsEs,
"game-badges": gameBadgesEsEs,
"game-misc": gameMiscEsEs,
@@ -729,6 +758,7 @@ export const resources = {
friends: friendsHe,
weapons: weaponsHe,
scrims: scrimsHe,
settings: settingsHe,
common: commonHe,
"game-badges": gameBadgesHe,
"game-misc": gameMiscHe,
@@ -755,6 +785,7 @@ export const resources = {
friends: friendsFrEu,
weapons: weaponsFrEu,
scrims: scrimsFrEu,
settings: settingsFrEu,
common: commonFrEu,
"game-badges": gameBadgesFrEu,
"game-misc": gameMiscFrEu,
@@ -781,6 +812,7 @@ export const resources = {
friends: friendsPl,
weapons: weaponsPl,
scrims: scrimsPl,
settings: settingsPl,
common: commonPl,
"game-badges": gameBadgesPl,
"game-misc": gameMiscPl,

View File

@@ -210,7 +210,7 @@ export default [
route("looking", "features/sendouq/routes/q.looking.tsx"),
route("preparing", "features/sendouq/routes/q.preparing.tsx"),
route("match/:id", "features/sendouq-match/routes/q.match.$id.tsx"),
route("settings", "features/sendouq-settings/routes/q.settings.tsx"),
route("settings", "features/match-profile/routes/q.settings.tsx"),
route("streams", "features/sendouq-streams/routes/q.streams.tsx"),
]),
route("/play", "features/sendouq/routes/play.ts"),

View File

@@ -28,6 +28,7 @@ const ALL_NAMESPACES = [
"org",
"front",
"friends",
"settings",
] as const;
assertType<Namespace, (typeof ALL_NAMESPACES)[number]>();
assertType<(typeof ALL_NAMESPACES)[number], Namespace>();

View File

@@ -112,7 +112,7 @@ export const LINKS_PAGE = "/links";
export const SENDOUQ_PAGE = "/q";
export const SENDOUQ_RULES_PAGE = "/q/rules";
export const SENDOUQ_INFO_PAGE = "/q/info";
export const SENDOUQ_SETTINGS_PAGE = "/q/settings";
export const MATCH_PROFILE_PAGE = "/settings?tab=match-profile";
export const SENDOUQ_PREPARING_PAGE = "/q/preparing";
export const SENDOUQ_LOOKING_PAGE = "/q/looking";
export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true";

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,11 @@
import type { Page } from "@playwright/test";
import {
clockFormatSchema,
disableBuildAbilitySortingSchema,
spoilerFreeModeSchema,
} from "~/features/settings/settings-schemas";
import {
CALENDAR_PAGE,
SETTINGS_PAGE,
tournamentBracketsPage,
tournamentResultsPage,
@@ -36,7 +38,7 @@ test.describe("Settings", () => {
await navigate({
page,
url: SETTINGS_PAGE,
url: `${SETTINGS_PAGE}?tab=preferences`,
});
const form = createFormHelpers(page, disableBuildAbilitySortingSchema);
@@ -55,10 +57,46 @@ test.describe("Settings", () => {
expect(newContents).not.toBe(oldContents);
});
test("updates clock format preference", async ({ page }) => {
await seed(page);
await impersonate(page);
await navigate({
page,
url: CALENDAR_PAGE,
});
const clockTime = page
.locator("[class*='clockHeader'] [class*='reserve-one-lb']")
.first();
const initialTime = await clockTime.textContent();
expect(initialTime).toMatch(/AM|PM/);
await navigate({
page,
url: `${SETTINGS_PAGE}?tab=locale`,
});
const form = createFormHelpers(page, clockFormatSchema);
await waitForPOSTResponse(page, () => form.select("newValue", "24h"));
await navigate({
page,
url: CALENDAR_PAGE,
});
const newTime = await clockTime.textContent();
expect(newTime).not.toMatch(/AM|PM/);
expect(newTime).not.toBe(initialTime);
expect(newTime).toContain(":");
});
});
const enableSpoilerFreeMode = async (page: Page) => {
await navigate({ page, url: SETTINGS_PAGE });
await navigate({ page, url: `${SETTINGS_PAGE}?tab=preferences` });
const form = createFormHelpers(page, spoilerFreeModeSchema);
await waitForPOSTResponse(page, () => form.check("newValue"));
};

View File

@@ -1,6 +1,6 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { ADMIN_DISCORD_ID } from "~/features/admin/admin-constants";
import { updateNoScreenSchema } from "~/features/settings/settings-schemas";
import { updateMatchProfileSchema } from "~/features/settings/match-profile-schemas";
import {
NOTIFICATIONS_URL,
SETTINGS_PAGE,
@@ -828,8 +828,9 @@ test.describe("Tournament bracket", () => {
url: SETTINGS_PAGE,
});
const form = createFormHelpers(page, updateNoScreenSchema);
await form.check("newValue");
const form = createFormHelpers(page, updateMatchProfileSchema);
await form.check("noScreen");
await waitForPOSTResponse(page, () => form.submit());
await navigate({
page,

View File

@@ -1,4 +1,4 @@
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import { BANNED_MAPS } from "~/features/match-profile/banned-maps";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { StageId } from "~/modules/in-game-lists/types";
import {

View File

@@ -124,7 +124,7 @@ test.describe("User page", () => {
(el) => el.style.getPropertyValue("--_base-h") !== "",
);
await navigate({ page, url: "/settings" });
await navigate({ page, url: "/settings?tab=theme" });
// initially no custom theme
await expect(hasCustomTheme()).resolves.toBe(false);

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/da/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/de/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "Season {{nth}} open",
"front.preview": "Preview groups in the queue without joining",
"front.preview.explanation": "This feature is only available to Supporter tier (or above) patrons of sendou.ink",
"settings.maps.header": "Stages and modes",
"settings.maps.personal": "Personal",
"settings.maps.preferencesFor": "Preferences for",
"settings.maps.teamExplanation": "Team map list is used for all members when queueing as a team",
"settings.maps.avoid": "Avoid",
"settings.maps.prefer": "Prefer",
"settings.maps.neutral": "Neutral",
"settings.weaponPool.header": "Weapon pool",
"settings.weaponPool.full": "Weapon pool is full",
"settings.voiceChat.header": "Voice chat",
"settings.voiceChat.canVC.header": "Can voice chat?",
"settings.voiceChat.canVC.yes": "Yes",
"settings.voiceChat.canVC.no": "No",
"settings.voiceChat.canVC.listenOnly": "Listen only",
"settings.voiceChat.languages.header": "Your languages",
"settings.voiceChat.languages.placeholder": "Select all that apply",
"settings.sounds.header": "Sounds",
"settings.sounds.likeReceived": "Group invitation received",
"settings.sounds.groupNewMember": "Group invitation accepted",
"settings.sounds.matchStarted": "SendouQ match started",
"settings.sounds.tournamentMatchStarted": "Tournament match started",
"settings.mapPool.notOk": "Pick {{count}} stages per mode that you didn't avoid to save your preferences",
"settings.misc.header": "Misc",
"settings.banned": "Banned",
"settings.avoid.label": "Avoid {{special}}",
"looking.joiningGroupError": "Before joining another group, leave the current one",
"looking.goToSettingsPrompt": "To help with group finding, set your weapon pool and voice chat status on the settings page",
"looking.inactiveGroup.soon": "Group will be marked inactive. Still looking?",

28
locales/en/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "Preferences",
"tabs.matchProfile": "Match profile",
"tabs.locale": "Locale",
"tabs.theme": "Theme",
"tabs.sounds": "Sounds",
"matchProfile.maps.header": "Stages and modes",
"matchProfile.maps.personal": "Personal",
"matchProfile.maps.preferencesFor": "Preferences for",
"matchProfile.maps.teamExplanation": "Team map list is used for all members when queueing as a team",
"matchProfile.maps.avoid": "Avoid",
"matchProfile.maps.prefer": "Prefer",
"matchProfile.maps.neutral": "Neutral",
"matchProfile.weaponPool.header": "Weapon pool",
"matchProfile.weaponPool.full": "Weapon pool is full",
"matchProfile.voiceChat.header": "Voice chat",
"matchProfile.voiceChat.canVC.header": "Can voice chat?",
"matchProfile.voiceChat.canVC.yes": "Yes",
"matchProfile.voiceChat.canVC.no": "No",
"matchProfile.voiceChat.canVC.listenOnly": "Listen only",
"matchProfile.voiceChat.languages.header": "Your languages",
"matchProfile.voiceChat.languages.placeholder": "Select all that apply",
"matchProfile.mapPool.notOk": "Pick {{count}} stages per mode that you didn't avoid to save your preferences",
"sounds.likeReceived": "Group invitation received",
"sounds.groupNewMember": "Group invitation accepted",
"sounds.matchStarted": "SendouQ match started",
"sounds.tournamentMatchStarted": "Tournament match started"
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "Temporada {{nth}} está abierta",
"front.preview": "Ver avance de grupos en fila sin unirte",
"front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink",
"settings.maps.header": "Mapas y modos",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "Evitar",
"settings.maps.prefer": "Preferir",
"settings.maps.neutral": "Neutral",
"settings.weaponPool.header": "Grupo de armas",
"settings.weaponPool.full": "Grupo de armas está lleno",
"settings.voiceChat.header": "Chat de voz",
"settings.voiceChat.canVC.header": "¿Se puede usar chat de voz?",
"settings.voiceChat.canVC.yes": "Sí",
"settings.voiceChat.canVC.no": "No",
"settings.voiceChat.canVC.listenOnly": "Solo escuchar",
"settings.voiceChat.languages.header": "Tus idiomas",
"settings.voiceChat.languages.placeholder": "Elegir los que correspondan",
"settings.sounds.header": "Sonidos",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "Partido de torneo comenzado",
"settings.mapPool.notOk": "Elige {{count}} mapas por modo que no evitaste para guardar tus preferencias",
"settings.misc.header": "Misc",
"settings.banned": "Prohibidos",
"settings.avoid.label": "Evitar {{special}}",
"looking.joiningGroupError": "Antes de unirte a otro grupo, deja el grupo actual",
"looking.goToSettingsPrompt": "Para encontrar grupos más fácil, elige tu grupo de armas y estado de chat en la página de preferencias",
"looking.inactiveGroup.soon": "Grupo será marcado como inactivo. ¿Aún estás buscando?",

View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "Sonidos",
"matchProfile.maps.header": "Mapas y modos",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "Evitar",
"matchProfile.maps.prefer": "Preferir",
"matchProfile.maps.neutral": "Neutral",
"matchProfile.weaponPool.header": "Grupo de armas",
"matchProfile.weaponPool.full": "Grupo de armas está lleno",
"matchProfile.voiceChat.header": "Chat de voz",
"matchProfile.voiceChat.canVC.header": "¿Se puede usar chat de voz?",
"matchProfile.voiceChat.canVC.yes": "Sí",
"matchProfile.voiceChat.canVC.no": "No",
"matchProfile.voiceChat.canVC.listenOnly": "Solo escuchar",
"matchProfile.voiceChat.languages.header": "Tus idiomas",
"matchProfile.voiceChat.languages.placeholder": "Elegir los que correspondan",
"matchProfile.mapPool.notOk": "Elige {{count}} mapas por modo que no evitaste para guardar tus preferencias",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": "Partido de torneo comenzado"
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "Temporada {{nth}} está abierta",
"front.preview": "Ver avance de grupos en fila sin unirte",
"front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink",
"settings.maps.header": "Escenarios y Estilos",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "Evitar",
"settings.maps.prefer": "Preferir",
"settings.maps.neutral": "Neutral",
"settings.weaponPool.header": "Grupo de armas",
"settings.weaponPool.full": "Grupo de armas está lleno",
"settings.voiceChat.header": "Chat de voz",
"settings.voiceChat.canVC.header": "¿Se puede usar chat de voz?",
"settings.voiceChat.canVC.yes": "Sí",
"settings.voiceChat.canVC.no": "No",
"settings.voiceChat.canVC.listenOnly": "Solo escuchar",
"settings.voiceChat.languages.header": "Tus idiomas",
"settings.voiceChat.languages.placeholder": "Elegir los que correspondan",
"settings.sounds.header": "Sonidos",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Elige {{count}} escenarios por estilo que no evitaste para guardar tus preferencias",
"settings.misc.header": "Misc",
"settings.banned": "Prohibidos",
"settings.avoid.label": "Evitar {{special}}",
"looking.joiningGroupError": "Antes de unirte a otro grupo, deja el corriente",
"looking.goToSettingsPrompt": "Para encontrar grupos más facil, elige tu grupo de armas y estado de chat en la página de preferencias",
"looking.inactiveGroup.soon": "Grupo será marcado como inactivo. ¿Aún estás buscando?",

View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "Sonidos",
"matchProfile.maps.header": "Escenarios y Estilos",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "Evitar",
"matchProfile.maps.prefer": "Preferir",
"matchProfile.maps.neutral": "Neutral",
"matchProfile.weaponPool.header": "Grupo de armas",
"matchProfile.weaponPool.full": "Grupo de armas está lleno",
"matchProfile.voiceChat.header": "Chat de voz",
"matchProfile.voiceChat.canVC.header": "¿Se puede usar chat de voz?",
"matchProfile.voiceChat.canVC.yes": "Sí",
"matchProfile.voiceChat.canVC.no": "No",
"matchProfile.voiceChat.canVC.listenOnly": "Solo escuchar",
"matchProfile.voiceChat.languages.header": "Tus idiomas",
"matchProfile.voiceChat.languages.placeholder": "Elegir los que correspondan",
"matchProfile.mapPool.notOk": "Elige {{count}} escenarios por estilo que no evitaste para guardar tus preferencias",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "La saison {{nth}} est ouverte",
"front.preview": "Regarder qui est dans la queue sans la rejoindre",
"front.preview.explanation": "Cette fonctionnalité n'est disponible que pour les utilisateurs de niveau Supporter (ou supérieur) sur le patreon de sendou.ink.",
"settings.maps.header": "Stages et modes",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "Détester",
"settings.maps.prefer": "Préférer",
"settings.maps.neutral": "Neutre",
"settings.weaponPool.header": "Arme jouée",
"settings.weaponPool.full": "Les armes jouer sont remplies",
"settings.voiceChat.header": "Chat vocal",
"settings.voiceChat.canVC.header": "Peux-tu utiliser le chat vocal?",
"settings.voiceChat.canVC.yes": "Oui",
"settings.voiceChat.canVC.no": "Non",
"settings.voiceChat.canVC.listenOnly": "Ecoute seulement",
"settings.voiceChat.languages.header": "Vos langues",
"settings.voiceChat.languages.placeholder": "Selectionner",
"settings.sounds.header": "Notifications",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Sélectionner {{count}} stages par mode que vous n'avez pas évité pour enregistrer vos préférences",
"settings.misc.header": "Divers",
"settings.banned": "Bannis",
"settings.avoid.label": "Sans {{special}}",
"looking.joiningGroupError": "Avant de rejoindre un nouveau groupe, quitté celui actuel",
"looking.goToSettingsPrompt": "Pour aider votre recherche de groupe, selectionner vos armes et votre statut vocal dans les paramètres",
"looking.inactiveGroup.soon": "Le groupe a été marqué comme inactif. Recherchez-vous toujours?",

View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "Notifications",
"matchProfile.maps.header": "Stages et modes",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "Détester",
"matchProfile.maps.prefer": "Préférer",
"matchProfile.maps.neutral": "Neutre",
"matchProfile.weaponPool.header": "Arme jouée",
"matchProfile.weaponPool.full": "Les armes jouer sont remplies",
"matchProfile.voiceChat.header": "Chat vocal",
"matchProfile.voiceChat.canVC.header": "Peux-tu utiliser le chat vocal?",
"matchProfile.voiceChat.canVC.yes": "Oui",
"matchProfile.voiceChat.canVC.no": "Non",
"matchProfile.voiceChat.canVC.listenOnly": "Ecoute seulement",
"matchProfile.voiceChat.languages.header": "Vos langues",
"matchProfile.voiceChat.languages.placeholder": "Selectionner",
"matchProfile.mapPool.notOk": "Sélectionner {{count}} stages par mode que vous n'avez pas évité pour enregistrer vos préférences",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/he/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "Stagione {{nth}} aperta",
"front.preview": "Visualizza gruppi nella coda prima di unirti",
"front.preview.explanation": "Questa funzione è disponibile solo per iscritti al Patreon di sendou.ink di tier Supporter o più",
"settings.maps.header": "Mappe e modalità",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "Evita",
"settings.maps.prefer": "Preferisci",
"settings.maps.neutral": "Neutrale",
"settings.weaponPool.header": "Pool armi",
"settings.weaponPool.full": "La pool armi è al completo",
"settings.voiceChat.header": "Voice chat",
"settings.voiceChat.canVC.header": "Puoi fare voice chat?",
"settings.voiceChat.canVC.yes": "Sì",
"settings.voiceChat.canVC.no": "No",
"settings.voiceChat.canVC.listenOnly": "Ascolta soltanto",
"settings.voiceChat.languages.header": "Le tue lingue",
"settings.voiceChat.languages.placeholder": "Seleziona quelle che preferisci",
"settings.sounds.header": "Suoni",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Scegli {{count}} mappe per modalità che non hai evitato per salvare le tue preferenze",
"settings.misc.header": "Misc",
"settings.banned": "Bannata",
"settings.avoid.label": "Evita {{special}}",
"looking.joiningGroupError": "Prima di unirti a un nuovo gruppo, lascia quello attuale",
"looking.goToSettingsPrompt": "Per aiutarti a trovare un gruppo, imposta la tua pool armi e stato voice chat nella pagina delle impostazioni",
"looking.inactiveGroup.soon": "Il gruppo verrà segnato come inattivo. Stai ancora cercando?",

28
locales/it/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "Suoni",
"matchProfile.maps.header": "Mappe e modalità",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "Evita",
"matchProfile.maps.prefer": "Preferisci",
"matchProfile.maps.neutral": "Neutrale",
"matchProfile.weaponPool.header": "Pool armi",
"matchProfile.weaponPool.full": "La pool armi è al completo",
"matchProfile.voiceChat.header": "Voice chat",
"matchProfile.voiceChat.canVC.header": "Puoi fare voice chat?",
"matchProfile.voiceChat.canVC.yes": "Sì",
"matchProfile.voiceChat.canVC.no": "No",
"matchProfile.voiceChat.canVC.listenOnly": "Ascolta soltanto",
"matchProfile.voiceChat.languages.header": "Le tue lingue",
"matchProfile.voiceChat.languages.placeholder": "Seleziona quelle che preferisci",
"matchProfile.mapPool.notOk": "Scegli {{count}} mappe per modalità che non hai evitato per salvare le tue preferenze",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "シーズン{{nth}}開幕",
"front.preview": "ジョインせず列にいるグループを見る",
"front.preview.explanation": "この機能はサポーター(あるいはそれ以上)の支援者のみ使えます。",
"settings.maps.header": "ステージ、モード",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "避ける",
"settings.maps.prefer": "好み",
"settings.maps.neutral": "普通",
"settings.weaponPool.header": "武器プール",
"settings.weaponPool.full": "武器プールがいっぱいです",
"settings.voiceChat.header": "ボイチャ",
"settings.voiceChat.canVC.header": "ボイチャできますか?",
"settings.voiceChat.canVC.yes": "はい",
"settings.voiceChat.canVC.no": "いいえ",
"settings.voiceChat.canVC.listenOnly": "聞き専",
"settings.voiceChat.languages.header": "自分の言語",
"settings.voiceChat.languages.placeholder": "該当するものを全て選んでください。",
"settings.sounds.header": "音",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)",
"settings.misc.header": "他",
"settings.banned": "禁止",
"settings.avoid.label": "避ける {{special}}",
"looking.joiningGroupError": "違うグループをジョインする前に今入っているやつを出てください",
"looking.goToSettingsPrompt": "グループ探しを助けるため武器プール、ボイチャ状態を設定画面でセットしてください",
"looking.inactiveGroup.soon": "グループが非活動とマークされます。まだ探していますか?",

28
locales/ja/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "音",
"matchProfile.maps.header": "ステージ、モード",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "避ける",
"matchProfile.maps.prefer": "好み",
"matchProfile.maps.neutral": "普通",
"matchProfile.weaponPool.header": "武器プール",
"matchProfile.weaponPool.full": "武器プールがいっぱいです",
"matchProfile.voiceChat.header": "ボイチャ",
"matchProfile.voiceChat.canVC.header": "ボイチャできますか?",
"matchProfile.voiceChat.canVC.yes": "はい",
"matchProfile.voiceChat.canVC.no": "いいえ",
"matchProfile.voiceChat.canVC.listenOnly": "聞き専",
"matchProfile.voiceChat.languages.header": "自分の言語",
"matchProfile.voiceChat.languages.placeholder": "該当するものを全て選んでください。",
"matchProfile.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/ko/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/nl/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "",
"front.preview": "",
"front.preview.explanation": "",
"settings.maps.header": "",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "",
"settings.maps.prefer": "",
"settings.maps.neutral": "",
"settings.weaponPool.header": "",
"settings.weaponPool.full": "",
"settings.voiceChat.header": "",
"settings.voiceChat.canVC.header": "",
"settings.voiceChat.canVC.yes": "",
"settings.voiceChat.canVC.no": "",
"settings.voiceChat.canVC.listenOnly": "",
"settings.voiceChat.languages.header": "",
"settings.voiceChat.languages.placeholder": "",
"settings.sounds.header": "",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "",
"settings.misc.header": "",
"settings.banned": "",
"settings.avoid.label": "",
"looking.joiningGroupError": "",
"looking.goToSettingsPrompt": "",
"looking.inactiveGroup.soon": "",

28
locales/pl/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "",
"matchProfile.maps.header": "",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "",
"matchProfile.maps.prefer": "",
"matchProfile.maps.neutral": "",
"matchProfile.weaponPool.header": "",
"matchProfile.weaponPool.full": "",
"matchProfile.voiceChat.header": "",
"matchProfile.voiceChat.canVC.header": "",
"matchProfile.voiceChat.canVC.yes": "",
"matchProfile.voiceChat.canVC.no": "",
"matchProfile.voiceChat.canVC.listenOnly": "",
"matchProfile.voiceChat.languages.header": "",
"matchProfile.voiceChat.languages.placeholder": "",
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -50,31 +50,6 @@
"front.seasonOpen": "Temporada {{nth}} aberta",
"front.preview": "Pré-visualizar grupos na fila sem entrar",
"front.preview.explanation": "Essa função está disponível apenas para patronos (ou patronesses) da tier Supporter (ou acima) do sendou.ink",
"settings.maps.header": "Mapas e modos",
"settings.maps.personal": "",
"settings.maps.preferencesFor": "",
"settings.maps.teamExplanation": "",
"settings.maps.avoid": "Evitar",
"settings.maps.prefer": "Prefere",
"settings.maps.neutral": "Neutro",
"settings.weaponPool.header": "Pool de armas",
"settings.weaponPool.full": "A pool de armas está cheia",
"settings.voiceChat.header": "Chat de voz",
"settings.voiceChat.canVC.header": "Pode usar chat de voz?",
"settings.voiceChat.canVC.yes": "Sim",
"settings.voiceChat.canVC.no": "Não",
"settings.voiceChat.canVC.listenOnly": "Posso apenas ouvir",
"settings.voiceChat.languages.header": "Seus idiomas",
"settings.voiceChat.languages.placeholder": "Escolha todos que se aplicam",
"settings.sounds.header": "Sons",
"settings.sounds.likeReceived": "",
"settings.sounds.groupNewMember": "",
"settings.sounds.matchStarted": "",
"settings.sounds.tournamentMatchStarted": "",
"settings.mapPool.notOk": "Escolha {{count}} mapas por modo que você não evitou para salvar suas preferências",
"settings.misc.header": "Diversos",
"settings.banned": "Banido(a)",
"settings.avoid.label": "Evitar {{special}}",
"looking.joiningGroupError": "Antes de entrar em outro grupo, saia do atual",
"looking.goToSettingsPrompt": "Para ajudar a encontrar um grupo defina sua pool de armas e status de chat de voz na página de configurações",
"looking.inactiveGroup.soon": "O grupo será marcado como inativo. Ainda procurando?",

View File

@@ -0,0 +1,28 @@
{
"tabs.preferences": "",
"tabs.matchProfile": "",
"tabs.locale": "",
"tabs.theme": "",
"tabs.sounds": "Sons",
"matchProfile.maps.header": "Mapas e modos",
"matchProfile.maps.personal": "",
"matchProfile.maps.preferencesFor": "",
"matchProfile.maps.teamExplanation": "",
"matchProfile.maps.avoid": "Evitar",
"matchProfile.maps.prefer": "Prefere",
"matchProfile.maps.neutral": "Neutro",
"matchProfile.weaponPool.header": "Pool de armas",
"matchProfile.weaponPool.full": "A pool de armas está cheia",
"matchProfile.voiceChat.header": "Chat de voz",
"matchProfile.voiceChat.canVC.header": "Pode usar chat de voz?",
"matchProfile.voiceChat.canVC.yes": "Sim",
"matchProfile.voiceChat.canVC.no": "Não",
"matchProfile.voiceChat.canVC.listenOnly": "Posso apenas ouvir",
"matchProfile.voiceChat.languages.header": "Seus idiomas",
"matchProfile.voiceChat.languages.placeholder": "Escolha todos que se aplicam",
"matchProfile.mapPool.notOk": "Escolha {{count}} mapas por modo que você não evitou para salvar suas preferências",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

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