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,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}
/>
);
}