mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-27 21:55:15 -05:00
Add auto theme option for detecting the theme from system/browser preferences (#1083)
* Add "auto" option to theme switcher * Add labels to language and user menu buttons * Update translation-progress.md
This commit is contained in:
@@ -14,10 +14,12 @@ export function Avatar({
|
||||
user,
|
||||
size = "sm",
|
||||
className,
|
||||
alt = "",
|
||||
...rest
|
||||
}: {
|
||||
user: Pick<User, "discordId" | "discordAvatar">;
|
||||
className?: string;
|
||||
alt?: string;
|
||||
size: keyof typeof dimensions;
|
||||
} & React.ButtonHTMLAttributes<HTMLImageElement>) {
|
||||
const [isErrored, setIsErrored] = React.useState(false);
|
||||
@@ -38,7 +40,8 @@ export function Avatar({
|
||||
}.webp${size === "lg" ? "?size=240" : "?size=80"}`
|
||||
: "/img/blank.gif" // avoid broken image placeholder
|
||||
}
|
||||
alt=""
|
||||
alt={alt}
|
||||
title={alt ? alt : undefined}
|
||||
width={dimensions[size]}
|
||||
height={dimensions[size]}
|
||||
// https://github.com/jsx-eslint/eslint-plugin-react/issues/3388
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export function GlobeIcon({ className }: { className?: string }) {
|
||||
export function GlobeIcon({
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -7,7 +13,11 @@ export function GlobeIcon({ className }: { className?: string }) {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export function MoonIcon({ className }: { className?: string }) {
|
||||
export function MoonIcon({
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -7,7 +13,11 @@ export function MoonIcon({ className }: { className?: string }) {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export function SunIcon({ className }: { className?: string }) {
|
||||
export function SunIcon({
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -7,7 +14,11 @@ export function SunIcon({ className }: { className?: string }) {
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
|
||||
28
app/components/icons/SunAndMoon.tsx
Normal file
28
app/components/icons/SunAndMoon.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
export function SunAndMoonIcon({
|
||||
className,
|
||||
alt,
|
||||
}: {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
d="m19.921 16.252-1.4411-1.4841m-8.6467-8.9048-1.4411-1.4841m5.8557-0.26889 0.03042-2.0685m4.3307 3.9505 1.4841-1.4411m0.2689 5.8557 2.0685 0.03042m-10.747 2.2802c-3.2025-3.2981 1.7446-8.1018 4.9471-4.8037 3.2012 3.298-1.7459 8.1018-4.9471 4.8037zm3.233 5.4901a7.0662 7.0662 0 0 1-2.7676 0.28142c-3.8978-0.37316-6.7547-3.8351-6.3816-7.7328 0.092161-0.96268 0.3725-1.8613 0.80141-2.6639a7.0917 7.0917 0 0 0-4.9653 6.1002c-0.37316 3.8978 2.4838 7.3597 6.3816 7.7328a7.0917 7.0917 0 0 0 6.9314-3.7177z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Theme, useTheme } from "~/modules/theme";
|
||||
import { MoonIcon } from "../icons/Moon";
|
||||
import { SunIcon } from "../icons/Sun";
|
||||
|
||||
export function ColorModeToggle() {
|
||||
const [, setTheme] = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prevTheme) =>
|
||||
prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="layout__header__button" onClick={toggleTheme}>
|
||||
<SunIcon className="light-mode-only layout__header__button__icon" />
|
||||
<MoonIcon className="dark-mode-only layout__header__button__icon" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,16 @@ import { GlobeIcon } from "../icons/Globe";
|
||||
import { Popover } from "../Popover";
|
||||
|
||||
export function LanguageChanger() {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<Popover
|
||||
buttonChildren={<GlobeIcon className="layout__header__button__icon" />}
|
||||
buttonChildren={
|
||||
<GlobeIcon
|
||||
alt={t("header.language")}
|
||||
className="layout__header__button__icon"
|
||||
/>
|
||||
}
|
||||
triggerClassName="layout__header__button"
|
||||
>
|
||||
<div className="layout__user-popover">
|
||||
|
||||
58
app/components/layout/ThemeChanger.tsx
Normal file
58
app/components/layout/ThemeChanger.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Theme, useTheme } from "~/modules/theme";
|
||||
import { Button } from "../Button";
|
||||
import { MoonIcon } from "../icons/Moon";
|
||||
import { SunIcon } from "../icons/Sun";
|
||||
import { SunAndMoonIcon } from "../icons/SunAndMoon";
|
||||
import { Popover } from "../Popover";
|
||||
|
||||
const ThemeIcons = {
|
||||
[Theme.LIGHT]: SunIcon,
|
||||
[Theme.DARK]: MoonIcon,
|
||||
auto: SunAndMoonIcon,
|
||||
};
|
||||
|
||||
export function ThemeChanger() {
|
||||
const { userTheme, setUserTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!userTheme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SelectedIcon = ThemeIcons[userTheme];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<SelectedIcon
|
||||
alt={t("header.theme")}
|
||||
className="layout__header__button__icon"
|
||||
/>
|
||||
}
|
||||
triggerClassName="layout__header__button"
|
||||
>
|
||||
<div className="layout__user-popover">
|
||||
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
|
||||
const Icon = ThemeIcons[theme];
|
||||
const selected = userTheme === theme;
|
||||
return (
|
||||
<Button
|
||||
variant="minimal"
|
||||
key={theme}
|
||||
tiny
|
||||
icon={<Icon alt="" />}
|
||||
// TODO: Remove this and find better semantic representation than
|
||||
// just multiple buttons. Maybe radio group?
|
||||
aria-current={selected}
|
||||
className={selected ? undefined : "text-main-forced"}
|
||||
onClick={() => setUserTheme(theme)}
|
||||
>
|
||||
{t(`theme.${theme}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,14 @@ export function UserItem() {
|
||||
return (
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<Avatar user={user} className="layout__avatar" size="sm" />
|
||||
<Avatar
|
||||
user={user}
|
||||
alt={t("header.loggedInAs", {
|
||||
userName: `${user.discordName}`,
|
||||
})}
|
||||
className="layout__avatar"
|
||||
size="sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="layout__user-popover">
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { RootLoaderData } from "~/root";
|
||||
import { type SendouRouteHandle } from "~/utils/remix";
|
||||
import { LOGO_PATH, navIconUrl } from "~/utils/urls";
|
||||
import { Image } from "../Image";
|
||||
import { ColorModeToggle } from "./ColorModeToggle";
|
||||
import { ThemeChanger } from "./ThemeChanger";
|
||||
import { Footer } from "./Footer";
|
||||
import { HamburgerButton } from "./HamburgerButton";
|
||||
import { LanguageChanger } from "./LanguageChanger";
|
||||
@@ -60,7 +60,7 @@ export const Layout = React.memo(function Layout({
|
||||
<div className="layout__header__right-container">
|
||||
{!isCatchBoundary ? <UserItem /> : null}
|
||||
<LanguageChanger />
|
||||
<ColorModeToggle />
|
||||
<ThemeChanger />
|
||||
<HamburgerButton
|
||||
expanded={menuOpen}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
|
||||
@@ -10,6 +10,13 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const form = new URLSearchParams(requestText);
|
||||
const theme = form.get("theme");
|
||||
|
||||
if (theme === "auto") {
|
||||
return json(
|
||||
{ success: true },
|
||||
{ headers: { "Set-Cookie": await themeSession.destroy() } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!isTheme(theme)) {
|
||||
return json({
|
||||
success: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
enum Theme {
|
||||
DARK = "dark",
|
||||
@@ -8,7 +8,19 @@ enum Theme {
|
||||
}
|
||||
const themes: Array<Theme> = Object.values(Theme);
|
||||
|
||||
type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>];
|
||||
type ThemeContextType = {
|
||||
/** The CSS class to attach to the `html` tag */
|
||||
htmlThemeClass: Theme | "";
|
||||
/** The color scheme to be defined in the meta tag */
|
||||
metaColorScheme: "light dark" | "dark light";
|
||||
/**
|
||||
* The Theme setting of the user, as displayed in the theme switcher.
|
||||
* `null` means there is no theme switcher (static theme on error pages).
|
||||
*/
|
||||
userTheme: Theme | "auto" | null;
|
||||
/** Persists a new `userTheme` setting */
|
||||
setUserTheme: (newTheme: Theme | "auto") => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
@@ -16,70 +28,91 @@ const prefersLightMQ = "(prefers-color-scheme: light)";
|
||||
const getPreferredTheme = () =>
|
||||
window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK;
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode;
|
||||
specifiedTheme: Theme | null;
|
||||
themeSource: "user-preference" | "static";
|
||||
};
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
specifiedTheme,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
specifiedTheme: Theme | null;
|
||||
}) {
|
||||
const [theme, setTheme] = useState<Theme | null>(() => {
|
||||
// On the server, if we don't have a specified theme then we should
|
||||
// return null and the clientThemeCode will set the theme for us
|
||||
// before hydration. Then (during hydration), this code will get the same
|
||||
// value that clientThemeCode got so hydration is happy.
|
||||
themeSource,
|
||||
}: ThemeProviderProps) {
|
||||
const [[theme, isAutoDetected], setThemeState] = useState<
|
||||
[Theme, false] | [Theme | null, true]
|
||||
>(() => {
|
||||
if (themeSource === "static") {
|
||||
return [specifiedTheme ?? Theme.DARK, false];
|
||||
}
|
||||
|
||||
if (specifiedTheme) {
|
||||
if (themes.includes(specifiedTheme)) {
|
||||
return specifiedTheme;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return [specifiedTheme, false];
|
||||
}
|
||||
|
||||
// there's no way for us to know what the theme should be in this context
|
||||
// the client will have to figure it out before hydration.
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
/*
|
||||
If we don't know a preferred user theme, we have to auto-detect it.
|
||||
|
||||
return getPreferredTheme();
|
||||
Since the server has no way of doing auto-detection, it returns null,
|
||||
leading to the `html` class and `color-scheme` values being set to a
|
||||
default.
|
||||
|
||||
Then, on the client, the `clientThemeCode` will run, correcting those
|
||||
defaults with the determined correct value.
|
||||
|
||||
Which means, when we later render this component again, hydration will
|
||||
succeed. Because the output of `getPreferredTheme()` is (very likely) the
|
||||
same that the `clientThemeCode` determined and added to the html element
|
||||
shortly before.
|
||||
*/
|
||||
|
||||
return [typeof document === "undefined" ? null : getPreferredTheme(), true];
|
||||
});
|
||||
|
||||
const persistTheme = useFetcher();
|
||||
// TODO: remove this when persistTheme is memoized properly
|
||||
const persistThemeRef = useRef(persistTheme);
|
||||
useEffect(() => {
|
||||
persistThemeRef.current = persistTheme;
|
||||
}, [persistTheme]);
|
||||
const persistThemeFetcher = useFetcher();
|
||||
const persistTheme = persistThemeFetcher.submit;
|
||||
|
||||
const mountRun = useRef(false);
|
||||
const setUserTheme = useCallback(
|
||||
(newTheme: Theme | "auto") => {
|
||||
setThemeState(
|
||||
newTheme === "auto" ? [getPreferredTheme(), true] : [newTheme, false]
|
||||
);
|
||||
persistTheme(
|
||||
{ theme: newTheme },
|
||||
{
|
||||
action: "theme",
|
||||
method: "post",
|
||||
}
|
||||
);
|
||||
},
|
||||
[setThemeState, persistTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mountRun.current) {
|
||||
mountRun.current = true;
|
||||
return;
|
||||
}
|
||||
if (!theme) {
|
||||
if (!isAutoDetected) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistThemeRef.current.submit(
|
||||
{ theme },
|
||||
{ action: "theme", method: "post" }
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(prefersLightMQ);
|
||||
const handleChange = () => {
|
||||
setTheme(mediaQuery.matches ? Theme.DARK : Theme.LIGHT);
|
||||
setThemeState([mediaQuery.matches ? Theme.LIGHT : Theme.DARK, true]);
|
||||
};
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, []);
|
||||
}, [isAutoDetected]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={[theme, setTheme]}>
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
// Gets corrected by clientThemeCode if set to "" during SSR
|
||||
htmlThemeClass: theme ?? "",
|
||||
// Gets corrected by clientThemeCode if set to wrong value during SSR
|
||||
metaColorScheme: theme === "light" ? "light dark" : "dark light",
|
||||
userTheme:
|
||||
themeSource === "static" ? null : isAutoDetected ? "auto" : theme!,
|
||||
setUserTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
@@ -117,8 +150,9 @@ const clientThemeCode = `
|
||||
})();
|
||||
`;
|
||||
|
||||
function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) {
|
||||
const [theme] = useTheme();
|
||||
function ThemeHead() {
|
||||
const { userTheme, metaColorScheme } = useTheme();
|
||||
const [initialUserTheme] = useState(userTheme);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,24 +160,19 @@ function ThemeHead({ ssrTheme }: { ssrTheme: boolean }) {
|
||||
On the server, "theme" might be `null`, so clientThemeCode ensures that
|
||||
this is correct before hydration.
|
||||
*/}
|
||||
<meta
|
||||
name="color-scheme"
|
||||
content={theme === "light" ? "light dark" : "dark light"}
|
||||
/>
|
||||
<meta name="color-scheme" content={metaColorScheme} />
|
||||
{/*
|
||||
If we know what the theme is from the server then we don't need
|
||||
If we know what the theme is from user preference, then we don't need
|
||||
to do fancy tricks prior to hydration to make things match.
|
||||
*/}
|
||||
{ssrTheme ? null : (
|
||||
<>
|
||||
<script
|
||||
// NOTE: we cannot use type="module" because that automatically makes
|
||||
// the script "defer". That doesn't work for us because we need
|
||||
// this script to run synchronously before the rest of the document
|
||||
// is finished loading.
|
||||
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
|
||||
/>
|
||||
</>
|
||||
{initialUserTheme === "auto" && (
|
||||
<script
|
||||
// NOTE: we cannot use type="module" because that automatically makes
|
||||
// the script "defer". That doesn't work for us because we need
|
||||
// this script to run synchronously before the rest of the document
|
||||
// is finished loading.
|
||||
dangerouslySetInnerHTML={{ __html: clientThemeCode }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ async function getThemeSession(request: Request) {
|
||||
},
|
||||
setTheme: (theme: Theme) => session.set("theme", theme),
|
||||
commit: () => themeStorage.commitSession(session),
|
||||
destroy: () => themeStorage.destroySession(session, { maxAge: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
26
app/root.tsx
26
app/root.tsx
@@ -35,6 +35,7 @@ import { COMMON_PREVIEW_IMAGE } from "./utils/urls";
|
||||
import { ConditionalScrollRestoration } from "./components/ConditionalScrollRestoration";
|
||||
import { type SendouRouteHandle } from "~/utils/remix";
|
||||
import generalI18next from "i18next";
|
||||
import { isTheme } from "./modules/theme/provider";
|
||||
|
||||
export const unstable_shouldReload: ShouldReloadFunction = ({ url }) => {
|
||||
// reload on language change so the selected language gets set into the cookie
|
||||
@@ -63,11 +64,16 @@ export const meta: MetaFunction = () => ({
|
||||
|
||||
export interface RootLoaderData {
|
||||
locale: string;
|
||||
theme: Theme | null;
|
||||
theme: string | null;
|
||||
patrons: FindAllPatrons;
|
||||
user?: Pick<
|
||||
UserWithPlusTier,
|
||||
"id" | "discordId" | "discordAvatar" | "plusTier" | "customUrl"
|
||||
| "id"
|
||||
| "discordId"
|
||||
| "discordAvatar"
|
||||
| "plusTier"
|
||||
| "customUrl"
|
||||
| "discordName"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -83,6 +89,7 @@ export const loader: LoaderFunction = async ({ request }) => {
|
||||
patrons: db.users.findAllPatrons(),
|
||||
user: user
|
||||
? {
|
||||
discordName: user.discordName,
|
||||
discordAvatar: user.discordAvatar,
|
||||
discordId: user.discordId,
|
||||
id: user.id,
|
||||
@@ -108,7 +115,7 @@ function Document({
|
||||
children: React.ReactNode;
|
||||
data?: RootLoaderData;
|
||||
}) {
|
||||
const [theme] = useTheme();
|
||||
const { htmlThemeClass } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
const locale = data?.locale ?? DEFAULT_LANGUAGE;
|
||||
|
||||
@@ -116,11 +123,11 @@ function Document({
|
||||
usePreloadTranslation();
|
||||
|
||||
return (
|
||||
<html lang={locale} dir={i18n.dir()} className={theme ?? ""}>
|
||||
<html lang={locale} dir={i18n.dir()} className={htmlThemeClass}>
|
||||
<head>
|
||||
<Meta />
|
||||
<Links />
|
||||
<ThemeHead ssrTheme={Boolean(data?.theme)} />
|
||||
<ThemeHead />
|
||||
</head>
|
||||
<body>
|
||||
<React.StrictMode>
|
||||
@@ -169,7 +176,10 @@ export default function App() {
|
||||
const data = useLoaderData<RootLoaderData>();
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={data?.theme ?? null}>
|
||||
<ThemeProvider
|
||||
specifiedTheme={isTheme(data.theme) ? data.theme : null}
|
||||
themeSource="user-preference"
|
||||
>
|
||||
<Document data={data}>
|
||||
<Outlet />
|
||||
</Document>
|
||||
@@ -179,7 +189,7 @@ export default function App() {
|
||||
|
||||
export function CatchBoundary() {
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={Theme.DARK}>
|
||||
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
@@ -191,7 +201,7 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<ThemeProvider specifiedTheme={Theme.DARK}>
|
||||
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"header.profile": "Profil",
|
||||
"header.logout": "Ausloggen",
|
||||
"header.login": "Einloggen",
|
||||
"header.language": "Sprache",
|
||||
"header.loggedInAs": "Eingeloggt als {{userName}}",
|
||||
"header.theme": "Theme",
|
||||
|
||||
"auth.errors.aborted": "Einloggen abgebrochen",
|
||||
"auth.errors.failed": "Einloggen fehlgeschlagen",
|
||||
@@ -83,5 +86,9 @@
|
||||
"weapon.category.DUALIES": "Doppler",
|
||||
"weapon.category.BRELLAS": "Pluviatoren",
|
||||
"weapon.category.STRINGERS": "Stringer",
|
||||
"weapon.category.SPLATANAS": "Splatanas"
|
||||
"weapon.category.SPLATANAS": "Splatanas",
|
||||
|
||||
"theme.light": "Hell",
|
||||
"theme.dark": "Dunkel",
|
||||
"theme.auto": "Auto"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"header.profile": "Profile",
|
||||
"header.logout": "Log out",
|
||||
"header.login": "Log in",
|
||||
"header.language": "Language",
|
||||
"header.loggedInAs": "Logged in as {{userName}}",
|
||||
"header.theme": "Theme",
|
||||
|
||||
"auth.errors.aborted": "Login Aborted",
|
||||
"auth.errors.failed": "Login Failed",
|
||||
@@ -84,5 +87,9 @@
|
||||
"weapon.category.DUALIES": "Dualies",
|
||||
"weapon.category.BRELLAS": "Brellas",
|
||||
"weapon.category.STRINGERS": "Stringers",
|
||||
"weapon.category.SPLATANAS": "Splatanas"
|
||||
"weapon.category.SPLATANAS": "Splatanas",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.auto": "Auto"
|
||||
}
|
||||
|
||||
@@ -20,12 +20,18 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**75/76**
|
||||
**75/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.articles
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -78,7 +84,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**75/76**
|
||||
**81/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -170,7 +176,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**48/76**
|
||||
**48/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -179,6 +185,9 @@
|
||||
- pages.s2
|
||||
- pages.maps
|
||||
- pages.object-damage-calculator
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
@@ -203,6 +212,9 @@
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -331,7 +343,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**47/76**
|
||||
**47/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -340,6 +352,9 @@
|
||||
- pages.analyzer
|
||||
- pages.maps
|
||||
- pages.object-damage-calculator
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
@@ -365,6 +380,9 @@
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -468,7 +486,7 @@
|
||||
|
||||
### 🔴 common.json
|
||||
|
||||
**0/76**
|
||||
**0/82**
|
||||
|
||||
### 🔴 contributions.json
|
||||
|
||||
@@ -528,12 +546,18 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**75/76**
|
||||
**75/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.articles
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -594,7 +618,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**35/76**
|
||||
**35/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -604,6 +628,9 @@
|
||||
- pages.analyzer
|
||||
- pages.maps
|
||||
- pages.object-damage-calculator
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
@@ -640,6 +667,9 @@
|
||||
- weapon.category.BRELLAS
|
||||
- weapon.category.STRINGERS
|
||||
- weapon.category.SPLATANAS
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -766,13 +796,16 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**55/76**
|
||||
**55/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.articles
|
||||
- pages.object-damage-calculator
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
@@ -792,6 +825,9 @@
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -883,12 +919,15 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**61/76**
|
||||
**61/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
|
||||
- pages.articles
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- actions.loading
|
||||
- actions.clear
|
||||
- actions.selectAll
|
||||
@@ -903,6 +942,9 @@
|
||||
- maps.template.preset.onlyMode
|
||||
- forms.errors.noSearchMatches
|
||||
- errors.genericReload
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
@@ -998,7 +1040,7 @@
|
||||
|
||||
### 🟡 common.json
|
||||
|
||||
**35/76**
|
||||
**35/82**
|
||||
|
||||
<details>
|
||||
<summary>Missing</summary>
|
||||
@@ -1008,6 +1050,9 @@
|
||||
- pages.analyzer
|
||||
- pages.maps
|
||||
- pages.object-damage-calculator
|
||||
- header.language
|
||||
- header.loggedInAs
|
||||
- header.theme
|
||||
- auth.errors.aborted
|
||||
- auth.errors.failed
|
||||
- auth.errors.discordPermissions
|
||||
@@ -1044,6 +1089,9 @@
|
||||
- weapon.category.BRELLAS
|
||||
- weapon.category.STRINGERS
|
||||
- weapon.category.SPLATANAS
|
||||
- theme.light
|
||||
- theme.dark
|
||||
- theme.auto
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user