Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,93 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { isSupporter } from "~/modules/permissions/utils";
import { clampThemeToGamut } from "~/utils/oklch-gamut";
import { errorToast } from "~/utils/remix.server";
import { toDBBoolean } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
import { settingsActionSchema } from "../settings-schemas.server";
export const action = async ({ request }: ActionFunctionArgs) => {
const user = requireUser();
const result = await parseFormData({
request,
schema: settingsActionSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const data = result.data;
switch (data._action) {
case "UPDATE_CUSTOM_THEME": {
if (!isSupporter(user)) {
throw errorToast("Custom themes are for supporters only");
}
const clampedTheme = data.newValue
? clampThemeToGamut(data.newValue)
: null;
await UserRepository.updateOwnCustomTheme(clampedTheme);
break;
}
case "UPDATE_DISABLE_BUILD_ABILITY_SORTING": {
await UserRepository.updateOwnPreferences({
disableBuildAbilitySorting: data.newValue,
});
break;
}
case "DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED": {
await UserRepository.updateOwnPreferences({
disallowScrimPickupsFromUntrusted: data.newValue,
});
break;
}
case "UPDATE_SPOILER_FREE_MODE": {
await UserRepository.updateOwnPreferences({
spoilerFreeMode: data.newValue,
});
break;
}
case "UPDATE_CLOCK_FORMAT": {
await UserRepository.updateOwnPreferences({
clockFormat: data.newValue,
});
break;
}
case "UPDATE_WEAPON_REPORT_DEFAULT_OPEN": {
await UserRepository.updateOwnPreferences({
weaponReportDefaultOpen: data.newValue,
});
break;
}
case "UPDATE_MATCH_PROFILE": {
const { mapModePreferencesChanged, noScreenChanged } =
await MatchProfileRepository.updateOwnMatchProfile({
mapModePreferences: data.mapModePreferences,
vc: data.vc,
languages: data.languages,
weaponPool: data.weaponPool,
noScreen: toDBBoolean(data.noScreen),
});
// Challenges are made based on the modes/preferences shown at that
// moment, so changing them must undo pending requests to/from the group.
if (mapModePreferencesChanged || noScreenChanged) {
await cancelActiveGroupLikes(user.id);
}
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@@ -0,0 +1,55 @@
import { useTranslation } from "react-i18next";
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 { useSearchParam } from "~/modules/search-params/hooks";
import { clockFormatSchema } from "../settings-schemas";
import { settingsSearchParams } from "../settings-search-params";
export function LocaleTab() {
const user = useUser();
return (
<div className="stack md">
<LanguageSelector />
{user ? (
<SendouForm
schema={clockFormatSchema}
defaultValues={{
newValue: user.preferences.clockFormat ?? "auto",
}}
mode="autoSubmit"
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
) : null}
</div>
);
}
function LanguageSelector() {
const { t, i18n } = useTranslation(["common"]);
const [, setLng] = useSearchParam(settingsSearchParams, "lng");
const languageItems = languages.map((lang) => ({
value: lang.code,
label: lang.name,
}));
const handleLanguageChange = (newLang: string | null) => {
if (!newLang) return;
setLng(newLang, { replace: false });
};
return (
<SelectFormField
label={t("common:header.language")}
items={languageItems}
value={i18n.language}
onChange={handleLanguageChange}
/>
);
}

View File

@@ -0,0 +1,99 @@
import * as React from "react";
import { ModeImage } from "~/components/Image";
import type { Preference, UserMapModePreferences } from "~/db/tables-json";
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 { modesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { ModeMapPoolPicker } from "./ModeMapPoolPicker";
import { PreferenceRadioGroup } from "./PreferenceRadioGroup";
export function preferencesFromRaw(
raw: UserMapModePreferences | null | undefined,
): 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)),
})),
};
}
export 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 });
}
onChange({ modes: newModePreferences, pool: value.pool });
};
const handlePoolChange = (mode: ModeShort, stages: StageId[]) => {
const filtered = value.pool.filter((p) => p.mode !== mode);
filtered.push({ mode, stages });
onChange({ ...value, pool: filtered });
};
const pickableModes = modesShort.filter((mode) => {
const mp = value.modes.find((p) => p.mode === mode);
return mp?.preference !== "AVOID";
});
const [selectedMode, setSelectedMode] = React.useState<ModeShort>(
modesShort[0],
);
const activeMode = pickableModes.includes(selectedMode)
? selectedMode
: pickableModes[0];
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>
{activeMode ? (
<ModeMapPoolPicker
mode={activeMode}
modeTabs={pickableModes}
onModeChange={setSelectedMode}
amountToPick={AMOUNT_OF_MAPS_IN_POOL_PER_MODE}
pool={value.pool.find((p) => p.mode === activeMode)?.stages ?? []}
onChange={(stages) => handlePoolChange(activeMode, stages)}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { Pencil } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useLoaderData, useLocation } from "react-router";
import { LinkButton } from "~/components/elements/Button";
import { FormMessage } from "~/components/FormMessage";
import type { UserMapModePreferences } from "~/db/tables-json";
import { userCardEditPage } from "~/features/user-card/user-card-urls";
import { SendouForm } from "~/form/SendouForm";
import type { loader } from "../loaders/settings.server";
import { updateMatchProfileSchema } from "../match-profile-schemas";
import {
MapModePreferencesField,
preferencesFromRaw,
} from "./MapModePreferencesField";
export function MatchProfileTab() {
const { t } = useTranslation(["user", "settings"]);
const data = useLoaderData<typeof loader>();
const location = useLocation();
const matchProfile = data.matchProfile;
if (!matchProfile) return null;
return (
<div className="stack md">
<LinkButton
to={userCardEditPage({
returnTo: `${location.pathname}${location.search}`,
})}
size="small"
variant="outlined"
icon={<Pencil />}
className="self-start"
>
{t("user:card.edit.title")}
</LinkButton>
<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),
}}
revalidateRoot
>
{({ FormField }) => (
<>
<FormField name="mapModePreferences">
{(props: {
value: unknown;
onChange: (value: UserMapModePreferences) => void;
}) => (
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
/>
)}
</FormField>
<FormMessage type="info">
{t("settings:matchProfile.maps.teamPreferencesHint")}
</FormMessage>
<FormField name="weaponPool" />
<FormField name="vc" />
<FormField name="languages" />
<FormField name="noScreen" />
</>
)}
</SendouForm>
</div>
);
}

View File

@@ -0,0 +1,151 @@
.container {
--map-width: 90px;
--map-height: 50px;
}
.slot {
font-size: var(--font-xs);
display: grid;
place-items: center;
color: var(--color-text-high);
border-radius: var(--radius-field);
font-weight: var(--weight-bold);
width: 24px;
height: 24px;
border: 2px dotted var(--color-bg-higher);
}
.slotIcon {
color: var(--color-success);
width: 16px;
height: 16px;
}
.slotPicked {
border-style: solid;
}
.mapButton {
background-image: var(--map-image-url);
background-size: cover;
height: var(--map-height);
width: var(--map-width);
border: none;
background-color: transparent;
transition:
filter,
opacity 0.2s;
border-radius: var(--radius-box);
&:active {
transform: none;
}
}
@keyframes wiggle {
0% {
transform: rotate(0deg);
}
25% {
transform: rotate(5deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0deg);
}
}
.mapButtonWiggle {
animation: wiggle 0.25s infinite;
animation-iteration-count: 1;
}
.mapButtonGreyedOut {
filter: grayscale(100%) !important;
opacity: 0.4 !important;
}
.mapButtonIcon {
position: absolute;
top: 2px;
color: var(--color-success);
width: 48px;
height: 48px;
cursor: pointer;
}
.mapButtonText {
position: absolute;
top: 14px;
text-transform: uppercase;
font-weight: var(--weight-bold);
cursor: not-allowed;
}
.mapButtonLabel {
font-size: var(--font-2xs);
color: var(--color-text-high);
font-weight: var(--weight-semi);
}
.mapButtonContainer {
width: var(--map-width);
text-align: center;
}
.divider {
font-size: var(--font-xs);
font-weight: var(--weight-semi);
text-transform: uppercase;
display: flex;
gap: var(--s-2);
&::before,
&::after {
border-bottom: 2px dotted var(--color-bg-higher);
}
}
.modeTabs {
display: flex;
gap: var(--s-1);
}
.modeTab {
display: grid;
place-items: center;
padding: var(--s-1-5);
border: var(--border-style);
border-radius: var(--radius-full);
background-color: transparent;
& img {
filter: grayscale(100%);
opacity: 0.5;
transition:
filter 0.2s,
opacity 0.2s;
}
&:hover img {
opacity: 0.8;
}
&:focus-visible {
outline: var(--focus-ring);
}
}
.modeTabActive {
background-color: var(--color-bg-higher);
& img {
filter: none;
opacity: 1;
}
}

View File

@@ -0,0 +1,205 @@
import clsx from "clsx";
import { Check } from "lucide-react";
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 styles from "./ModeMapPoolPicker.module.css";
export function ModeMapPoolPicker({
mode,
amountToPick,
pool,
tiebreaker,
onChange,
modeTabs,
onModeChange,
disabled,
}: {
mode: ModeShort;
amountToPick: number;
pool: StageId[];
tiebreaker?: StageId;
onChange: (stages: StageId[]) => void;
/** When provided, the divider becomes a tab switcher between these modes. */
modeTabs?: ModeShort[];
onModeChange?: (mode: ModeShort) => void;
/** When true, stages can't be picked or removed (view-only). */
disabled?: boolean;
}) {
const [wigglingStageId, setWigglingStageId] = React.useState<StageId | null>(
null,
);
const stages: (StageId | null)[] = [
...pool,
...nullFilledArray(amountToPick - pool.length),
];
const handlePickedStageClick = (stageId: StageId) => {
onChange(pool.filter((s) => s !== stageId));
};
const handleUnpickedStageClick = (stageId: StageId) => {
// is there space left?
if (stages[amountToPick - 1] !== null) {
setWigglingStageId(stageId);
return;
}
// was it already picked?
if (pool.includes(stageId)) {
return;
}
onChange([...pool, stageId].sort((a, b) => a - b));
};
return (
<div className={clsx(styles.container, "stack sm")}>
<div className="stack sm horizontal justify-center">
{nullFilledArray(amountToPick).map((_, index) => {
return (
<MapSlot
key={index}
number={index + 1}
picked={stages[index] !== null}
/>
);
})}
</div>
<Divider className={styles.divider}>
{modeTabs && onModeChange ? (
<div className={styles.modeTabs}>
{modeTabs.map((tabMode) => {
const active = tabMode === mode;
return (
<button
key={tabMode}
type="button"
className={clsx(styles.modeTab, {
[styles.modeTabActive]: active,
})}
onClick={() => onModeChange(tabMode)}
aria-pressed={active}
data-testid={`map-pool-mode-tab-${tabMode}`}
>
<ModeImage mode={tabMode} size={24} />
</button>
);
})}
</div>
) : (
<ModeImage mode={mode} size={32} />
)}
</Divider>
<div className="stack sm horizontal flex-wrap justify-center mt-1">
{stageIds.map((stageId) => {
const isTiebreaker = tiebreaker === stageId;
const banned = BANNED_MAPS[mode].includes(stageId);
const selected = stages.includes(stageId);
const onClick = () => {
if (disabled) return;
if (isTiebreaker) return;
if (banned) return;
if (selected) return handlePickedStageClick(stageId);
handleUnpickedStageClick(stageId);
};
return (
<MapButton
key={stageId}
stageId={stageId}
onClick={onClick}
selected={selected}
banned={banned}
tiebreaker={isTiebreaker}
wiggle={wigglingStageId === stageId}
onWiggleEnd={() => setWigglingStageId(null)}
disabled={disabled}
testId={`map-pool-${mode}-${stageId}`}
/>
);
})}
</div>
</div>
);
}
function MapSlot({ number, picked }: { number: number; picked: boolean }) {
return (
<div
className={clsx(styles.slot, {
[styles.slotPicked]: picked,
})}
>
{picked ? <Check className={styles.slotIcon} /> : number}
</div>
);
}
function MapButton({
stageId,
onClick,
selected,
banned,
tiebreaker,
wiggle,
onWiggleEnd,
disabled,
testId,
}: {
stageId: StageId;
onClick: () => void;
selected?: boolean;
banned?: boolean;
tiebreaker?: boolean;
wiggle?: boolean;
onWiggleEnd?: () => void;
disabled?: boolean;
testId: string;
}) {
const { t } = useTranslation(["game-misc"]);
return (
<div
className={clsx("stack items-center relative", styles.mapButtonContainer)}
>
<button
className={clsx(styles.mapButton, {
[styles.mapButtonWiggle]: wiggle,
[styles.mapButtonGreyedOut]: selected || banned || tiebreaker,
})}
style={{ "--map-image-url": `url("${stageImageUrl(stageId)}.avif")` }}
onClick={onClick}
onAnimationEnd={wiggle ? onWiggleEnd : undefined}
disabled={disabled || banned}
type="button"
data-testid={testId}
/>
{selected ? (
<Check
className={styles.mapButtonIcon}
onClick={onClick}
data-testid={`${testId}-picked`}
/>
) : null}
{tiebreaker ? (
<div className={styles.mapButtonText}>Tiebreak</div>
) : banned ? (
<div className={clsx(styles.mapButtonText, "text-error")}>Banned</div>
) : null}
<div className={styles.mapButtonLabel}>
{shortStageName(t(`game-misc:STAGE_${stageId}`))}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
.radio {
background-color: var(--color-bg-high);
font-size: var(--font-xs);
text-transform: uppercase;
font-weight: var(--weight-semi);
color: var(--color-text-high);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
display: flex;
align-items: center;
gap: var(--s-1);
cursor: pointer;
}
.emoji {
filter: grayscale(100%);
transition: all 0.2s;
}
.checked {
color: var(--color-text);
outline: 2px solid var(--color-bg-higher);
& .emoji {
filter: grayscale(0%);
}
}
.focusVisible {
outline: var(--focus-ring);
}
.radio:hover .emoji {
scale: 1.1;
}

View File

@@ -0,0 +1,85 @@
import clsx from "clsx";
import { Radio, RadioGroup } from "react-aria-components";
import { useTranslation } from "react-i18next";
import type { Preference } from "~/db/tables-json";
import { preferenceEmojiUrl } from "~/utils/urls";
import styles from "./PreferenceRadioGroup.module.css";
export function PreferenceRadioGroup({
preference,
onPreferenceChange,
"aria-label": ariaLabel,
}: {
preference?: Preference;
onPreferenceChange: (preference: Preference & "NEUTRAL") => void;
"aria-label": string;
}) {
const { t } = useTranslation(["settings"]);
return (
<RadioGroup
value={preference ?? "NEUTRAL"}
onChange={(newPreference) =>
onPreferenceChange(newPreference as Preference & "NEUTRAL")
}
className="stack horizontal xs"
aria-label={ariaLabel}
orientation="horizontal"
>
<Radio value="AVOID" aria-label="Avoid the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl("AVOID")}
className={styles.emoji}
width={18}
alt="Avoid emoji"
/>
{t("settings:matchProfile.maps.avoid")}
</span>
)}
</Radio>
<Radio value="NEUTRAL" aria-label="Neutral towards the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl()}
className={styles.emoji}
width={18}
alt="Neutral emoji"
/>
{t("settings:matchProfile.maps.neutral")}
</span>
)}
</Radio>
<Radio value="PREFER" aria-label="Prefer the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl("PREFER")}
className={styles.emoji}
width={18}
alt="Prefer emoji"
/>
{t("settings:matchProfile.maps.prefer")}
</span>
)}
</Radio>
</RadioGroup>
);
}

View File

@@ -0,0 +1,177 @@
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 {
findPushSubscription,
isPushSupported,
subscribeToPush,
} from "~/features/notifications/core/pushSubscription";
import { SendouForm } from "~/form/SendouForm";
import { useHydrated } from "~/hooks/useHydrated";
import { logger } from "~/utils/logger";
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,
}}
mode="autoSubmit"
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={disallowScrimPickupsFromUntrustedSchema}
defaultValues={{
newValue:
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
}}
mode="autoSubmit"
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
<SendouForm
schema={spoilerFreeModeSchema}
defaultValues={{
newValue: user.preferences.spoilerFreeMode ?? false,
}}
mode="autoSubmit"
revalidateRoot
fullWidth
>
{({ FormField }) => <FormField name="newValue" />}
</SendouForm>
</div>
</div>
);
}
function PushNotificationsEnabler() {
const { t } = useTranslation(["common"]);
const isHydrated = useHydrated();
const [requestedPermission, setRequestedPermission] =
React.useState<NotificationPermission | null>(null);
const [subscribeFailed, setSubscribeFailed] = React.useState(false);
const [hasSubscription, setHasSubscription] = React.useState<boolean | null>(
null,
);
const subscribeInFlight = React.useRef(false);
const notificationsPermsGranted: NotificationPermission | "not-supported" =
requestedPermission ??
(!isHydrated
? "default"
: !isPushSupported()
? "not-supported"
: Notification.permission);
React.useEffect(() => {
if (notificationsPermsGranted !== "granted") return;
let cancelled = false;
findPushSubscription().then((subscription) => {
if (!cancelled && !subscribeInFlight.current) {
setHasSubscription(Boolean(subscription));
}
});
return () => {
cancelled = true;
};
}, [notificationsPermsGranted]);
async function askPermission() {
const permission = await Notification.requestPermission();
setRequestedPermission(permission);
if (permission !== "granted") return;
subscribeInFlight.current = true;
try {
setSubscribeFailed(false);
await subscribeToPush();
setHasSubscription(true);
} catch (err) {
logger.error("Failed to enable push notifications", err);
setSubscribeFailed(true);
} finally {
subscribeInFlight.current = false;
}
}
return (
<div>
<Label>{t("common:settings.notifications.title")}</Label>
{subscribeFailed ? (
<SendouButton size="small" variant="minimal" onPress={askPermission}>
{t("common:actions.enable")}
</SendouButton>
) : notificationsPermsGranted === "granted" &&
hasSubscription === false ? (
<SendouButton size="small" variant="minimal" onPress={askPermission}>
{t("common:actions.enable")}
</SendouButton>
) : 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>
)}
{subscribeFailed ? (
<FormMessage type="error">
{t("common:settings.notifications.subscribeFailed")}
</FormMessage>
) : notificationsPermsGranted === "granted" &&
hasSubscription === false ? (
<FormMessage type="info">
{t("common:settings.notifications.resubscribeNeeded")}
</FormMessage>
) : null}
<FormMessage type="info">
{t("common:settings.notifications.description")}
</FormMessage>
</div>
);
}

View File

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

View File

@@ -0,0 +1,107 @@
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_ready-check", name: t("settings:sounds.readyCheckStarted") },
{ code: "sq_match", name: t("settings:sounds.matchStarted") },
{
code: "tournament_match",
name: t("settings:sounds.tournamentMatchStarted"),
},
];
const currentValue = (code: string) =>
!localStorage.getItem(soundCodeToLocalStorageKey(code)) ||
localStorage.getItem(soundCodeToLocalStorageKey(code)) === "true";
const [soundValues, setSoundValues] = React.useState(
Object.fromEntries(
sounds.map((sound) => [sound.code, currentValue(sound.code)]),
),
);
const toggleSound = (code: string) => {
localStorage.setItem(
soundCodeToLocalStorageKey(code),
String(!currentValue(code)),
);
setSoundValues((prev) => ({
...prev,
[code]: !prev[code],
}));
};
return (
<div className="stack sm">
{sounds.map((sound) => (
<div key={sound.code}>
<label className="stack horizontal xs items-center">
<input
type="checkbox"
checked={soundValues[sound.code]}
onChange={() => toggleSound(sound.code)}
/>
{sound.name}
</label>
</div>
))}
</div>
);
}
function SoundSlider() {
const [volume, setVolume] = React.useState(() => soundVolume() || 100);
const changeVolume = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = Number.parseFloat(event.target.value);
setVolume(newVolume);
localStorage.setItem(
"settings__sound-volume",
String(Math.floor(newVolume)),
);
};
const playSound = () => {
const audio = new Audio(soundPath("sq_like"));
audio.volume = soundVolume() / 100;
void audio.play();
};
return (
<div className="stack horizontal xs items-center">
<Volume2 className={styles.volumeSliderIcon} />
<input
className={styles.volumeSliderInput}
type="range"
value={volume}
onChange={changeVolume}
onTouchEnd={playSound}
onMouseUp={playSound}
/>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useTranslation } from "react-i18next";
import { 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 { useActionSubmit } from "~/hooks/useActionSubmit";
import { useHasRole } from "~/modules/permissions/hooks";
import type { RootLoaderData } from "~/root";
import type { ThemeInput } from "~/utils/oklch-gamut";
import { customThemeSchema } from "../settings-schemas";
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.loaderData as RootLoaderData | undefined;
const isSupporter = useHasRole("SUPPORTER");
const { submit, state } = useActionSubmit(customThemeSchema, {
encType: "application/json",
});
const handleSave = (themeInput: ThemeInput) => {
submit("UPDATE_CUSTOM_THEME", {
newValue: themeInput,
revalidateRoot: true,
});
};
const handleReset = () => {
submit("UPDATE_CUSTOM_THEME", { newValue: null, revalidateRoot: true });
};
return (
<CustomThemeSelector
isPersonalTheme
initialTheme={rootData?.customTheme}
isSupporter={isSupporter}
onSave={handleSave}
onReset={handleReset}
fetcherState={state}
/>
);
}

View File

@@ -0,0 +1,17 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
export const loader = async (_args: LoaderFunctionArgs) => {
const user = getUser();
if (!user) {
return { matchProfile: null };
}
const matchProfile = await MatchProfileRepository.findSettingsByUserId(
user.id,
);
return { matchProfile };
};

View File

@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { mapModePreferencesValueSchema } from "./match-profile-schemas";
describe("mapModePreferencesValueSchema", () => {
test("strips pools for avoided modes", () => {
const result = mapModePreferencesValueSchema.parse({
modes: [
{ mode: "SZ", preference: "PREFER" },
{ mode: "TC", preference: "AVOID" },
],
pool: [
{ mode: "SZ", stages: [1, 2] },
{ mode: "TC", stages: [3, 4] },
],
});
expect(result.pool).toEqual([{ mode: "SZ", stages: [1, 2] }]);
});
test("keeps pools for preferred and neutral modes", () => {
const result = mapModePreferencesValueSchema.parse({
modes: [{ mode: "SZ", preference: "PREFER" }],
pool: [
{ mode: "SZ", stages: [1] },
{ mode: "TC", stages: [2] },
],
});
expect(result.pool).toEqual([
{ mode: "SZ", stages: [1] },
{ mode: "TC", stages: [2] },
]);
});
test("does not mutate the modes selection", () => {
const result = mapModePreferencesValueSchema.parse({
modes: [{ mode: "TC", preference: "AVOID" }],
pool: [{ mode: "TC", stages: [1] }],
});
expect(result.modes).toEqual([{ mode: "TC", preference: "AVOID" }]);
expect(result.pool).toEqual([]);
});
});

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import type { UserMapModePreferences } from "~/db/tables-json";
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();
export 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,
}),
),
})
// Pools for avoided modes are kept in the client form state so they can be
// restored if the user later un-avoids the mode, but they must not be
// persisted as active pools. Strip them out before the value reaches the action.
.transform((val) => ({
...val,
pool: val.pool.filter((pool) => {
const mp = val.modes.find((m) => m.mode === pool.mode);
return mp?.preference !== "AVOID";
}),
}));
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,
}),
noScreen: toggle({
label: "labels.noScreen",
bottomText: "bottomTexts.noScreen",
}),
});

View File

@@ -0,0 +1,20 @@
::view-transition-old(root),
.dark::view-transition-old(root) {
animation: none;
animation-fill-mode: both;
z-index: -1;
}
::view-transition-new(root) {
mask: url("./wave.svg") no-repeat;
mask-position: 50% 200%;
mask-size: max(100vw, 200vh);
animation: sweep 1.5s;
animation-fill-mode: both;
}
@keyframes sweep {
to {
mask-position: 50% 0%;
}
}

View File

@@ -0,0 +1,139 @@
import {
Globe,
LogOut,
Map as MapIcon,
Palette,
SlidersHorizontal,
Volume2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Main } from "~/components/Main";
import { useUser } from "~/features/auth/core/user";
import { useSearchParam } from "~/modules/search-params/hooks";
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 {
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 { settingsSearchParams } from "../settings-search-params";
import { defaultTab, resolveActiveTab } from "../settings-utils";
import "./settings.global.css";
export { action, loader };
export const handle: SendouRouteHandle = {
i18n: ["settings", "user"],
breadcrumb: () => ({
imgPath: navIconUrl("settings"),
href: SETTINGS_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Settings",
location: args.location,
});
};
export default function SettingsPage() {
const user = useUser();
const { t } = useTranslation(["common", "settings"]);
const [tab, setTab] = useSearchParam(settingsSearchParams, "tab");
const isLoggedIn = Boolean(user);
const activeTab = resolveActiveTab(tab, isLoggedIn);
const handleSelectionChange = (key: React.Key) => {
const slug = key as SettingsTabSlug;
setTab(slug === defaultTab(isLoggedIn) ? null : slug);
};
return (
<Main>
<div className="stack md">
<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}>
<SendouButton
size="small"
variant="outlined"
icon={<LogOut />}
type="submit"
>
{t("common:header.logout")}
</SendouButton>
</form>
) : null}
</div>
<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>
);
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
import { hidden, select, stringConstant, toggle } from "~/form/fields";
import { themeInputSchema } from "~/utils/zod";
export const customThemeSchema = z.object({
_action: stringConstant("UPDATE_CUSTOM_THEME"),
newValue: hidden(themeInputSchema.nullable(), null),
revalidateRoot: z.literal(true).nullish(),
});
export const clockFormatSchema = z.object({
_action: stringConstant("UPDATE_CLOCK_FORMAT"),
newValue: select({
label: "labels.clockFormat",
items: [
{ value: "auto", label: "options.clockFormat.auto" },
{ value: "24h", label: "options.clockFormat.24h" },
{ value: "12h", label: "options.clockFormat.12h" },
],
}),
});
export const disableBuildAbilitySortingSchema = z.object({
_action: stringConstant("UPDATE_DISABLE_BUILD_ABILITY_SORTING"),
newValue: toggle({
label: "labels.disableBuildAbilitySorting",
bottomText: "bottomTexts.disableBuildAbilitySorting",
}),
});
export const disallowScrimPickupsFromUntrustedSchema = z.object({
_action: stringConstant("DISALLOW_SCRIM_PICKUPS_FROM_UNTRUSTED"),
newValue: toggle({
label: "labels.disallowScrimPickupsFromUntrusted",
bottomText: "bottomTexts.disallowScrimPickupsFromUntrusted",
}),
});
export const spoilerFreeModeSchema = z.object({
_action: stringConstant("UPDATE_SPOILER_FREE_MODE"),
newValue: toggle({
label: "labels.spoilerFreeMode",
bottomText: "bottomTexts.spoilerFreeMode",
}),
});
export const weaponReportDefaultOpenSchema = z.object({
_action: stringConstant("UPDATE_WEAPON_REPORT_DEFAULT_OPEN"),
newValue: z.boolean(),
});
export const settingsEditSchema = z.union([
customThemeSchema,
disableBuildAbilitySortingSchema,
disallowScrimPickupsFromUntrustedSchema,
spoilerFreeModeSchema,
clockFormatSchema,
weaponReportDefaultOpenSchema,
]);

View File

@@ -0,0 +1,19 @@
import { describe, test } from "vitest";
import {
assertDecodesToDefault,
assertRoundTrips,
} from "~/modules/search-params/search-params-test-utils";
import { settingsSearchParams } from "./settings-search-params";
describe("settingsSearchParams", () => {
test("round-trips", () => {
assertRoundTrips(settingsSearchParams, {
tab: [null, "preferences", "match-profile", "locale", "theme", "sounds"],
lng: [null, "en", "fr", "zh-TW"],
});
});
test("malformed values decode to defaults", () => {
assertDecodesToDefault(settingsSearchParams, "tab", [["garbage"]]);
});
});

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
import { SETTINGS_TAB_SLUGS } from "./settings-constants";
export const settingsSearchParams = SearchParams.define({
tab: SP.param(z.enum(SETTINGS_TAB_SLUGS).nullable(), { loader: false }),
lng: SP.param(z.string().nullable(), { loader: true }),
});

View File

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