mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.volumeSliderIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.volumeSliderInput {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
107
apps/web-react/app/features/settings/components/SoundsTab.tsx
Normal file
107
apps/web-react/app/features/settings/components/SoundsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/web-react/app/features/settings/components/ThemeTab.tsx
Normal file
80
apps/web-react/app/features/settings/components/ThemeTab.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
139
apps/web-react/app/features/settings/routes/settings.tsx
Normal file
139
apps/web-react/app/features/settings/routes/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
apps/web-react/app/features/settings/routes/wave.svg
Normal file
8
apps/web-react/app/features/settings/routes/wave.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
@@ -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];
|
||||
@@ -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,
|
||||
]);
|
||||
59
apps/web-react/app/features/settings/settings-schemas.ts
Normal file
59
apps/web-react/app/features/settings/settings-schemas.ts
Normal 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,
|
||||
]);
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
});
|
||||
19
apps/web-react/app/features/settings/settings-utils.ts
Normal file
19
apps/web-react/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);
|
||||
}
|
||||
Reference in New Issue
Block a user