mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
335 lines
9.0 KiB
TypeScript
335 lines
9.0 KiB
TypeScript
import { LogOut } from "lucide-react";
|
|
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { MetaFunction } from "react-router";
|
|
import {
|
|
useFetcher,
|
|
useLoaderData,
|
|
useNavigate,
|
|
useSearchParams,
|
|
} from "react-router";
|
|
import { CustomThemeSelector } from "~/components/CustomThemeSelector";
|
|
import { Divider } from "~/components/Divider";
|
|
import { FormMessage } from "~/components/FormMessage";
|
|
import { Label } from "~/components/Label";
|
|
import { Main } from "~/components/Main";
|
|
import { useUser } from "~/features/auth/core/user";
|
|
import { Theme, useTheme } from "~/features/theme/core/provider";
|
|
import { SelectFormField } from "~/form/fields/SelectFormField";
|
|
import { SendouForm } from "~/form/SendouForm";
|
|
import { languages } from "~/modules/i18n/config";
|
|
import { useHasRole } from "~/modules/permissions/hooks";
|
|
import { metaTags } from "~/utils/remix";
|
|
import type { SendouRouteHandle } from "~/utils/remix.server";
|
|
import { LOG_OUT_URL, navIconUrl, SETTINGS_PAGE } from "~/utils/urls";
|
|
import { SendouButton } from "../../../components/elements/Button";
|
|
import { SendouPopover } from "../../../components/elements/Popover";
|
|
import { action } from "../actions/settings.server";
|
|
import { loader } from "../loaders/settings.server";
|
|
import {
|
|
clockFormatSchema,
|
|
disableBuildAbilitySortingSchema,
|
|
disallowScrimPickupsFromUntrustedSchema,
|
|
updateNoScreenSchema,
|
|
} from "../settings-schemas";
|
|
import styles from "./settings.module.css";
|
|
import "./settings.global.css";
|
|
import type { ThemeInput } from "~/utils/oklch-gamut";
|
|
|
|
export { action, loader };
|
|
|
|
export const handle: SendouRouteHandle = {
|
|
breadcrumb: () => ({
|
|
imgPath: navIconUrl("settings"),
|
|
href: SETTINGS_PAGE,
|
|
type: "IMAGE",
|
|
}),
|
|
};
|
|
|
|
export default function SettingsPage() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const user = useUser();
|
|
const { t } = useTranslation(["common"]);
|
|
|
|
return (
|
|
<Main halfWidth>
|
|
<div className="stack md">
|
|
<div className="stack horizontal justify-between">
|
|
<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>
|
|
<Divider className={styles.divider} smallText>
|
|
{t("common:settings.locales")}
|
|
</Divider>
|
|
<LanguageSelector />
|
|
{user ? (
|
|
<SendouForm
|
|
schema={clockFormatSchema}
|
|
defaultValues={{
|
|
newValue: user.preferences.clockFormat ?? "auto",
|
|
}}
|
|
autoSubmit
|
|
revalidateRoot
|
|
>
|
|
{({ FormField }) => <FormField name="newValue" />}
|
|
</SendouForm>
|
|
) : null}
|
|
{user ? (
|
|
<>
|
|
<Divider className={styles.divider} smallText>
|
|
{t("common:settings.preferences")}
|
|
</Divider>
|
|
<PushNotificationsEnabler />
|
|
<div className="mt-6 stack md">
|
|
<SendouForm
|
|
schema={disableBuildAbilitySortingSchema}
|
|
defaultValues={{
|
|
newValue:
|
|
user.preferences.disableBuildAbilitySorting ?? false,
|
|
}}
|
|
autoSubmit
|
|
revalidateRoot
|
|
>
|
|
{({ FormField }) => <FormField name="newValue" />}
|
|
</SendouForm>
|
|
<SendouForm
|
|
schema={disallowScrimPickupsFromUntrustedSchema}
|
|
defaultValues={{
|
|
newValue:
|
|
user.preferences.disallowScrimPickupsFromUntrusted ?? false,
|
|
}}
|
|
autoSubmit
|
|
revalidateRoot
|
|
>
|
|
{({ FormField }) => <FormField name="newValue" />}
|
|
</SendouForm>
|
|
<SendouForm
|
|
schema={updateNoScreenSchema}
|
|
defaultValues={{
|
|
newValue: Boolean(data.noScreen),
|
|
}}
|
|
autoSubmit
|
|
revalidateRoot
|
|
>
|
|
{({ FormField }) => <FormField name="newValue" />}
|
|
</SendouForm>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
<Divider className={styles.divider} smallText>
|
|
{t("common:settings.theme")}
|
|
</Divider>
|
|
<ThemeSelector />
|
|
<CustomColorSelector />
|
|
<FormMessage type="info">{t("common:settings.themeInfo")}</FormMessage>
|
|
</div>
|
|
</Main>
|
|
);
|
|
}
|
|
|
|
export const meta: MetaFunction = (args) => {
|
|
return metaTags({
|
|
title: "Settings",
|
|
location: args.location,
|
|
});
|
|
};
|
|
|
|
function LanguageSelector() {
|
|
const { t } = useTranslation(["common"]);
|
|
const { i18n } = useTranslation();
|
|
const [searchParams] = useSearchParams();
|
|
const navigate = useNavigate();
|
|
|
|
const languageItems = languages.map((lang) => ({
|
|
value: lang.code,
|
|
label: lang.name,
|
|
}));
|
|
|
|
const handleLanguageChange = (newLang: string | null) => {
|
|
if (!newLang) return;
|
|
navigate(`?${addUniqueParam(searchParams, "lng", newLang).toString()}`);
|
|
};
|
|
|
|
return (
|
|
<SelectFormField
|
|
label={t("common:header.language")}
|
|
items={languageItems}
|
|
value={i18n.language}
|
|
onChange={handleLanguageChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function addUniqueParam(
|
|
oldParams: URLSearchParams,
|
|
name: string,
|
|
value: string,
|
|
): URLSearchParams {
|
|
const paramsCopy = new URLSearchParams(oldParams);
|
|
paramsCopy.delete(name);
|
|
paramsCopy.append(name, value);
|
|
return paramsCopy;
|
|
}
|
|
|
|
function ThemeSelector() {
|
|
const { t } = useTranslation(["common"]);
|
|
const { userTheme, setUserTheme } = useTheme();
|
|
|
|
const themeItems = (["auto", Theme.DARK, Theme.LIGHT] as const).map(
|
|
(theme) => ({
|
|
value: theme,
|
|
label: t(`common:theme.${theme}`),
|
|
}),
|
|
);
|
|
|
|
const handleThemeChange = (newTheme: string | null) => {
|
|
if (!newTheme) return;
|
|
setUserTheme(newTheme as Theme);
|
|
};
|
|
|
|
return (
|
|
<SelectFormField
|
|
label={t("common:header.theme")}
|
|
items={themeItems}
|
|
value={userTheme ?? "auto"}
|
|
onChange={handleThemeChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function CustomColorSelector() {
|
|
const data = useLoaderData<typeof loader>();
|
|
const isSupporter = useHasRole("SUPPORTER");
|
|
const fetcher = useFetcher();
|
|
|
|
const handleSave = (themeInput: ThemeInput) => {
|
|
fetcher.submit(
|
|
{
|
|
_action: "UPDATE_CUSTOM_THEME",
|
|
newValue: themeInput,
|
|
revalidateRoot: true,
|
|
} as unknown as Parameters<typeof fetcher.submit>[0],
|
|
{ method: "post", encType: "application/json" },
|
|
);
|
|
};
|
|
|
|
const handleReset = () => {
|
|
fetcher.submit(
|
|
{ _action: "UPDATE_CUSTOM_THEME", newValue: null, revalidateRoot: true },
|
|
{ method: "post", encType: "application/json" },
|
|
);
|
|
};
|
|
|
|
return (
|
|
<CustomThemeSelector
|
|
isPersonalTheme
|
|
initialTheme={data.customTheme}
|
|
isSupporter={isSupporter}
|
|
onSave={handleSave}
|
|
onReset={handleReset}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
|
|
function PushNotificationsEnabler() {
|
|
const { t } = useTranslation(["common"]);
|
|
const [notificationsPermsGranted, setNotificationsPermsGranted] =
|
|
React.useState<NotificationPermission | "not-supported">("default");
|
|
|
|
React.useEffect(() => {
|
|
if (!("serviceWorker" in navigator)) {
|
|
// Service Worker isn't supported on this browser, disable or hide UI.
|
|
setNotificationsPermsGranted("not-supported");
|
|
return;
|
|
}
|
|
|
|
if (!("PushManager" in window)) {
|
|
// Push isn't supported on this browser, disable or hide UI.
|
|
setNotificationsPermsGranted("not-supported");
|
|
return;
|
|
}
|
|
|
|
setNotificationsPermsGranted(Notification.permission);
|
|
}, []);
|
|
|
|
function askPermission() {
|
|
Notification.requestPermission().then((permission) => {
|
|
setNotificationsPermsGranted(permission);
|
|
if (permission === "granted") {
|
|
initServiceWorker();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initServiceWorker() {
|
|
const swRegistration = await navigator.serviceWorker.register("sw-2.js");
|
|
const subscription = await swRegistration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
sendSubscriptionToServer(subscription);
|
|
} else {
|
|
const subscription = await swRegistration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
|
|
});
|
|
sendSubscriptionToServer(subscription);
|
|
}
|
|
}
|
|
|
|
function sendSubscriptionToServer(subscription: PushSubscription) {
|
|
fetch("/notifications/subscribe", {
|
|
method: "post",
|
|
body: JSON.stringify(subscription),
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Label>{t("common:settings.notifications.title")}</Label>
|
|
{notificationsPermsGranted === "granted" ? (
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton size="small" variant="minimal">
|
|
{t("common:actions.disable")}
|
|
</SendouButton>
|
|
}
|
|
>
|
|
{t("common:settings.notifications.disableInfo")}
|
|
</SendouPopover>
|
|
) : notificationsPermsGranted === "not-supported" ||
|
|
notificationsPermsGranted === "denied" ? (
|
|
<SendouPopover
|
|
trigger={
|
|
<SendouButton size="small" variant="minimal">
|
|
{t("common:actions.enable")}
|
|
</SendouButton>
|
|
}
|
|
>
|
|
{notificationsPermsGranted === "not-supported"
|
|
? t("common:settings.notifications.browserNotSupported")
|
|
: t("common:settings.notifications.permissionDenied")}
|
|
</SendouPopover>
|
|
) : (
|
|
<SendouButton size="small" variant="minimal" onPress={askPermission}>
|
|
{t("common:actions.enable")}
|
|
</SendouButton>
|
|
)}
|
|
<FormMessage type="info">
|
|
{t("common:settings.notifications.description")}
|
|
</FormMessage>
|
|
</div>
|
|
);
|
|
}
|