mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-06 19:27:58 -05:00
/q/settings -> /settings (#3085)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
3
app/features/match-profile/match-profile-constants.ts
Normal file
3
app/features/match-profile/match-profile-constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MATCH_PROFILE_WEAPON_POOL_MAX_SIZE = 4;
|
||||
|
||||
export const AMOUNT_OF_MAPS_IN_POOL_PER_MODE = 7;
|
||||
10
app/features/match-profile/routes/q.settings.tsx
Normal file
10
app/features/match-profile/routes/q.settings.tsx
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export const SENDOUQ_WEAPON_POOL_MAX_SIZE = 4;
|
||||
|
||||
export const AMOUNT_OF_MAPS_IN_POOL_PER_MODE = 7;
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'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'd rather not
|
||||
play the mode. Neutral means you don't have strong feelings either
|
||||
way. Prefer means you like this mode over neutral modes. These choices
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
58
app/features/settings/components/LocaleTab.tsx
Normal file
58
app/features/settings/components/LocaleTab.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
146
app/features/settings/components/MatchProfileTab.tsx
Normal file
146
app/features/settings/components/MatchProfileTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
@@ -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>
|
||||
150
app/features/settings/components/PreferencesTab.tsx
Normal file
150
app/features/settings/components/PreferencesTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
app/features/settings/components/SoundsTab.module.css
Normal file
10
app/features/settings/components/SoundsTab.module.css
Normal file
@@ -0,0 +1,10 @@
|
||||
.volumeSliderIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.volumeSliderInput {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
106
app/features/settings/components/SoundsTab.tsx
Normal file
106
app/features/settings/components/SoundsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
app/features/settings/components/ThemeTab.tsx
Normal file
83
app/features/settings/components/ThemeTab.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
74
app/features/settings/match-profile-schemas.ts
Normal file
74
app/features/settings/match-profile-schemas.ts
Normal 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",
|
||||
}),
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
.divider {
|
||||
margin-top: var(--s-4);
|
||||
margin-bottom: calc(var(--s-2) * -1);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
9
app/features/settings/settings-constants.ts
Normal file
9
app/features/settings/settings-constants.ts
Normal 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];
|
||||
8
app/features/settings/settings-schemas.server.ts
Normal file
8
app/features/settings/settings-schemas.server.ts
Normal 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,
|
||||
]);
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
19
app/features/settings/settings-utils.ts
Normal file
19
app/features/settings/settings-utils.ts
Normal 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);
|
||||
}
|
||||
@@ -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"),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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";
|
||||
|
||||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
28
locales/da/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/de/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/en/settings.json
Normal 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"
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
28
locales/es-ES/settings.json
Normal file
28
locales/es-ES/settings.json
Normal 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"
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
28
locales/es-US/settings.json
Normal file
28
locales/es-US/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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/fr-CA/settings.json
Normal file
28
locales/fr-CA/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
28
locales/fr-EU/settings.json
Normal file
28
locales/fr-EU/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/he/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/it/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/ja/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/ko/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/nl/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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
28
locales/pl/settings.json
Normal 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": ""
|
||||
}
|
||||
@@ -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?",
|
||||
|
||||
28
locales/pt-BR/settings.json
Normal file
28
locales/pt-BR/settings.json
Normal 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
Reference in New Issue
Block a user