Optional - if you don't have a preference then skip this step and
other players in the lobby get to choose. On the{" "}
- settings page first select your
+ settings page 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
diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts
index 1bcf93c40..122a26fc1 100644
--- a/app/features/sendouq/routes/q.looking.test.ts
+++ b/app/features/sendouq/routes/q.looking.test.ts
@@ -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";
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index 131a79d05..72b208898 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -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() {
>
diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts
index 157d7a783..77b761479 100644
--- a/app/features/settings/actions/settings.server.ts
+++ b/app/features/settings/actions/settings.server.ts
@@ -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;
};
diff --git a/app/features/settings/components/LocaleTab.tsx b/app/features/settings/components/LocaleTab.tsx
new file mode 100644
index 000000000..482f056e4
--- /dev/null
+++ b/app/features/settings/components/LocaleTab.tsx
@@ -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 (
+
+
+ {user ? (
+
+ {({ FormField }) => }
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/app/features/settings/components/MatchProfileTab.tsx b/app/features/settings/components/MatchProfileTab.tsx
new file mode 100644
index 000000000..b8ae2d1fe
--- /dev/null
+++ b/app/features/settings/components/MatchProfileTab.tsx
@@ -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
();
+ const matchProfile = data.matchProfile;
+
+ if (!matchProfile) return null;
+
+ return (
+ ({
+ id: w.weaponSplId,
+ isFavorite: Boolean(w.isFavorite),
+ })),
+ vc: matchProfile.vc ?? "NO",
+ languages: matchProfile.languages ?? [],
+ noScreen: Boolean(matchProfile.noScreen),
+ noSplatnet: Boolean(matchProfile.noSplatnet),
+ }}
+ revalidateRoot
+ >
+ {({ FormField }) => (
+ <>
+
+ {(props: {
+ value: unknown;
+ onChange: (value: UserMapModePreferences) => void;
+ }) => (
+
+ )}
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+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 (
+
+
+ {modesShort.map((modeShort) => {
+ const preference = value.modes.find(
+ (preference) => preference.mode === modeShort,
+ );
+
+ return (
+
+
+
+ handleModePreferenceChange({ mode: modeShort, preference })
+ }
+ aria-label={`Select preference towards ${modeShort}`}
+ />
+
+ );
+ })}
+
+
+
+ {modesShort.map((mode) => {
+ const mp = value.modes.find((p) => p.mode === mode);
+ if (mp?.preference === "AVOID") return null;
+
+ return (
+ p.mode === mode)?.stages ?? []}
+ onChange={(stages) => handlePoolChange(mode, stages)}
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/app/features/sendouq-settings/components/ModeMapPoolPicker.module.css b/app/features/settings/components/ModeMapPoolPicker.module.css
similarity index 100%
rename from app/features/sendouq-settings/components/ModeMapPoolPicker.module.css
rename to app/features/settings/components/ModeMapPoolPicker.module.css
diff --git a/app/features/sendouq-settings/components/ModeMapPoolPicker.tsx b/app/features/settings/components/ModeMapPoolPicker.tsx
similarity index 98%
rename from app/features/sendouq-settings/components/ModeMapPoolPicker.tsx
rename to app/features/settings/components/ModeMapPoolPicker.tsx
index 8cb9f3ce3..99ea5f85e 100644
--- a/app/features/sendouq-settings/components/ModeMapPoolPicker.tsx
+++ b/app/features/settings/components/ModeMapPoolPicker.tsx
@@ -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({
diff --git a/app/features/sendouq-settings/components/PreferenceRadioGroup.module.css b/app/features/settings/components/PreferenceRadioGroup.module.css
similarity index 100%
rename from app/features/sendouq-settings/components/PreferenceRadioGroup.module.css
rename to app/features/settings/components/PreferenceRadioGroup.module.css
diff --git a/app/features/sendouq-settings/components/PreferenceRadioGroup.tsx b/app/features/settings/components/PreferenceRadioGroup.tsx
similarity index 91%
rename from app/features/sendouq-settings/components/PreferenceRadioGroup.tsx
rename to app/features/settings/components/PreferenceRadioGroup.tsx
index 4f9ecd40a..833f87c29 100644
--- a/app/features/sendouq-settings/components/PreferenceRadioGroup.tsx
+++ b/app/features/settings/components/PreferenceRadioGroup.tsx
@@ -14,7 +14,7 @@ export function PreferenceRadioGroup({
onPreferenceChange: (preference: Preference & "NEUTRAL") => void;
"aria-label": string;
}) {
- const { t } = useTranslation(["q"]);
+ const { t } = useTranslation(["settings"]);
return (
- {t("q:settings.maps.avoid")}
+ {t("settings:matchProfile.maps.avoid")}
)}
@@ -58,7 +58,7 @@ export function PreferenceRadioGroup({
width={18}
alt="Neutral emoji"
/>
- {t("q:settings.maps.neutral")}
+ {t("settings:matchProfile.maps.neutral")}
)}
@@ -76,7 +76,7 @@ export function PreferenceRadioGroup({
width={18}
alt="Prefer emoji"
/>
- {t("q:settings.maps.prefer")}
+ {t("settings:matchProfile.maps.prefer")}
)}
diff --git a/app/features/settings/components/PreferencesTab.tsx b/app/features/settings/components/PreferencesTab.tsx
new file mode 100644
index 000000000..3225c09bf
--- /dev/null
+++ b/app/features/settings/components/PreferencesTab.tsx
@@ -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 (
+
+
+
+
+
+ {({ FormField }) => }
+
+
+ {({ FormField }) => }
+
+
+ {({ FormField }) => }
+
+
+
+ );
+}
+
+function PushNotificationsEnabler() {
+ const { t } = useTranslation(["common"]);
+ const [notificationsPermsGranted, setNotificationsPermsGranted] =
+ React.useState("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 (
+
+ {t("common:settings.notifications.title")}
+ {notificationsPermsGranted === "granted" ? (
+
+ {t("common:actions.disable")}
+
+ }
+ >
+ {t("common:settings.notifications.disableInfo")}
+
+ ) : notificationsPermsGranted === "not-supported" ||
+ notificationsPermsGranted === "denied" ? (
+
+ {t("common:actions.enable")}
+
+ }
+ >
+ {notificationsPermsGranted === "not-supported"
+ ? t("common:settings.notifications.browserNotSupported")
+ : t("common:settings.notifications.permissionDenied")}
+
+ ) : (
+
+ {t("common:actions.enable")}
+
+ )}
+
+ {t("common:settings.notifications.description")}
+
+
+ );
+}
diff --git a/app/features/settings/components/SoundsTab.module.css b/app/features/settings/components/SoundsTab.module.css
new file mode 100644
index 000000000..053247568
--- /dev/null
+++ b/app/features/settings/components/SoundsTab.module.css
@@ -0,0 +1,10 @@
+.volumeSliderIcon {
+ width: 16px;
+ height: 16px;
+}
+
+.volumeSliderInput {
+ padding-left: 0 !important;
+ padding-right: 0 !important;
+ border: 0 !important;
+}
diff --git a/app/features/settings/components/SoundsTab.tsx b/app/features/settings/components/SoundsTab.tsx
new file mode 100644
index 000000000..cf5d9d175
--- /dev/null
+++ b/app/features/settings/components/SoundsTab.tsx
@@ -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 (
+
+ {isHydrated ? : null}
+ {isHydrated ? : null}
+
+ );
+}
+
+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 (
+
+ {sounds.map((sound) => (
+
+
+ toggleSound(sound.code)}
+ />
+ {sound.name}
+
+
+ ))}
+
+ );
+}
+
+function SoundSlider() {
+ const [volume, setVolume] = React.useState(() => soundVolume() || 100);
+
+ const changeVolume = (event: React.ChangeEvent) => {
+ 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 (
+
+
+
+
+ );
+}
diff --git a/app/features/settings/components/ThemeTab.tsx b/app/features/settings/components/ThemeTab.tsx
new file mode 100644
index 000000000..42842b6d7
--- /dev/null
+++ b/app/features/settings/components/ThemeTab.tsx
@@ -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 (
+
+
+
+ {t("common:settings.themeInfo")}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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[0],
+ { method: "post", encType: "application/json" },
+ );
+ };
+
+ const handleReset = () => {
+ fetcher.submit(
+ { _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true },
+ { method: "post", encType: "application/json" },
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/app/features/settings/loaders/settings.server.ts b/app/features/settings/loaders/settings.server.ts
index 15e913061..08a8b3b69 100644
--- a/app/features/settings/loaders/settings.server.ts
+++ b/app/features/settings/loaders/settings.server.ts
@@ -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 };
};
diff --git a/app/features/settings/match-profile-schemas.ts b/app/features/settings/match-profile-schemas.ts
new file mode 100644
index 000000000..381a327f6
--- /dev/null
+++ b/app/features/settings/match-profile-schemas.ts
@@ -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",
+ }),
+});
diff --git a/app/features/settings/routes/settings.module.css b/app/features/settings/routes/settings.module.css
deleted file mode 100644
index a627cda46..000000000
--- a/app/features/settings/routes/settings.module.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.divider {
- margin-top: var(--s-4);
- margin-bottom: calc(var(--s-2) * -1);
-}
diff --git a/app/features/settings/routes/settings.tsx b/app/features/settings/routes/settings.tsx
index f924f9427..00b563f53 100644
--- a/app/features/settings/routes/settings.tsx
+++ b/app/features/settings/routes/settings.tsx
@@ -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();
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 (
-
+
-
+
{t("common:pages.settings")}
{user ? (
) : null}
-
- {t("common:settings.locales")}
-
-
- {user ? (
-
- {({ FormField }) => }
-
- ) : null}
- {user ? (
- <>
-
- {t("common:settings.preferences")}
-
-
-
-
- {({ FormField }) => }
-
-
- {({ FormField }) => }
-
-
- {({ FormField }) => }
-
-
- {({ FormField }) => }
-
-
- {({ FormField }) => }
-
-
- >
- ) : null}
-
- {t("common:settings.theme")}
-
-
-
-
{t("common:settings.themeInfo")}
+
+
+ {user ? (
+ }>
+ {t("settings:tabs.matchProfile")}
+
+ ) : null}
+ {user ? (
+ }>
+ {t("settings:tabs.preferences")}
+
+ ) : null}
+ }>
+ {t("settings:tabs.locale")}
+
+ }>
+ {t("settings:tabs.theme")}
+
+ {user ? (
+ }>
+ {t("settings:tabs.sounds")}
+
+ ) : null}
+
+ {user ? (
+
+
+
+ ) : null}
+ {user ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+ {user ? (
+
+
+
+ ) : null}
+
);
}
-
-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 (
-
- );
-}
-
-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 (
-
- );
-}
-
-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
[0],
- { method: "post", encType: "application/json" },
- );
- };
-
- const handleReset = () => {
- fetcher.submit(
- { _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true },
- { method: "post", encType: "application/json" },
- );
- };
-
- return (
-
- );
-}
-
-// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
-function PushNotificationsEnabler() {
- const { t } = useTranslation(["common"]);
- const [notificationsPermsGranted, setNotificationsPermsGranted] =
- React.useState("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 (
-
- {t("common:settings.notifications.title")}
- {notificationsPermsGranted === "granted" ? (
-
- {t("common:actions.disable")}
-
- }
- >
- {t("common:settings.notifications.disableInfo")}
-
- ) : notificationsPermsGranted === "not-supported" ||
- notificationsPermsGranted === "denied" ? (
-
- {t("common:actions.enable")}
-
- }
- >
- {notificationsPermsGranted === "not-supported"
- ? t("common:settings.notifications.browserNotSupported")
- : t("common:settings.notifications.permissionDenied")}
-
- ) : (
-
- {t("common:actions.enable")}
-
- )}
-
- {t("common:settings.notifications.description")}
-
-
- );
-}
diff --git a/app/features/settings/settings-constants.ts b/app/features/settings/settings-constants.ts
new file mode 100644
index 000000000..ce8fd9e6e
--- /dev/null
+++ b/app/features/settings/settings-constants.ts
@@ -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];
diff --git a/app/features/settings/settings-schemas.server.ts b/app/features/settings/settings-schemas.server.ts
new file mode 100644
index 000000000..ddd072ddd
--- /dev/null
+++ b/app/features/settings/settings-schemas.server.ts
@@ -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,
+]);
diff --git a/app/features/settings/settings-schemas.ts b/app/features/settings/settings-schemas.ts
index b8fd0b0db..538a8380a 100644
--- a/app/features/settings/settings-schemas.ts
+++ b/app/features/settings/settings-schemas.ts
@@ -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,
]);
diff --git a/app/features/settings/settings-utils.ts b/app/features/settings/settings-utils.ts
new file mode 100644
index 000000000..293a31303
--- /dev/null
+++ b/app/features/settings/settings-utils.ts
@@ -0,0 +1,19 @@
+import { SETTINGS_TAB_SLUGS, type SettingsTabSlug } from "./settings-constants";
+
+const PUBLIC_TABS = new Set(["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);
+}
diff --git a/app/features/tournament-lfg/TournamentLFGRepository.server.ts b/app/features/tournament-lfg/TournamentLFGRepository.server.ts
index 26fd51415..375aa8cdc 100644
--- a/app/features/tournament-lfg/TournamentLFGRepository.server.ts
+++ b/app/features/tournament-lfg/TournamentLFGRepository.server.ts
@@ -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"),
}),
])
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx
index afb73af83..1db01a842 100644
--- a/app/features/tournament/routes/to.$id.register.tsx
+++ b/app/features/tournament/routes/to.$id.register.tsx
@@ -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";
diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts
index 3e0a0bb6c..8868bf382 100644
--- a/app/features/tournament/tournament-utils.ts
+++ b/app/features/tournament/tournament-utils.ts
@@ -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";
diff --git a/app/form/SendouForm.module.css b/app/form/SendouForm.module.css
index 3088885fa..10b675290 100644
--- a/app/form/SendouForm.module.css
+++ b/app/form/SendouForm.module.css
@@ -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 {
diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx
index b0b6d47c6..636c1228f 100644
--- a/app/form/SendouForm.tsx
+++ b/app/form/SendouForm.tsx
@@ -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 = {
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>) => void;
secondarySubmit?: React.ReactNode;
};
@@ -89,6 +96,7 @@ export function SendouForm({
autoApply,
revalidateRoot,
className,
+ fullWidth,
onApply,
secondarySubmit,
}: SendouFormProps) {
@@ -388,15 +396,18 @@ export function SendouForm({
>
);
+ const resolvedClassName =
+ className ?? clsx(styles.form, { [styles.fullWidth]: fullWidth });
+
return (
{autoApply && onApply ? (
- {formContent}
+ {formContent}
) : (