diff --git a/app/components/FormWithConfirm.tsx b/app/components/FormWithConfirm.tsx
index edcebcca8..8631e566d 100644
--- a/app/components/FormWithConfirm.tsx
+++ b/app/components/FormWithConfirm.tsx
@@ -36,9 +36,7 @@ export function FormWithConfirm({
-
+
diff --git a/app/components/icons/Moon.tsx b/app/components/icons/Moon.tsx
new file mode 100644
index 000000000..6490af5c9
--- /dev/null
+++ b/app/components/icons/Moon.tsx
@@ -0,0 +1,32 @@
+export function MoonIcon({
+ className,
+ alt,
+ size,
+}: {
+ className?: string;
+ alt: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/app/components/icons/Sun.tsx b/app/components/icons/Sun.tsx
new file mode 100644
index 000000000..82cf5e728
--- /dev/null
+++ b/app/components/icons/Sun.tsx
@@ -0,0 +1,33 @@
+export function SunIcon({
+ className,
+ alt,
+ size,
+}: {
+ className?: string;
+ alt: string;
+ title?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/app/components/icons/SunAndMoon.tsx b/app/components/icons/SunAndMoon.tsx
new file mode 100644
index 000000000..1f9a45e5b
--- /dev/null
+++ b/app/components/icons/SunAndMoon.tsx
@@ -0,0 +1,32 @@
+export function SunAndMoonIcon({
+ className,
+ alt,
+ size,
+}: {
+ className?: string;
+ alt?: string;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/app/components/layout/SelectedThemeIcon.tsx b/app/components/layout/SelectedThemeIcon.tsx
new file mode 100644
index 000000000..878bfa639
--- /dev/null
+++ b/app/components/layout/SelectedThemeIcon.tsx
@@ -0,0 +1,28 @@
+import { useTranslation } from "~/hooks/useTranslation";
+import { Theme, useTheme } from "~/modules/theme";
+import { MoonIcon } from "../icons/Moon";
+import { SunIcon } from "../icons/Sun";
+import { SunAndMoonIcon } from "../icons/SunAndMoon";
+
+const ThemeIcons = {
+ [Theme.LIGHT]: SunIcon,
+ [Theme.DARK]: MoonIcon,
+ auto: SunAndMoonIcon,
+};
+
+export function SelectedThemeIcon({ size }: { size?: number }) {
+ const { t } = useTranslation();
+ const { userTheme } = useTheme();
+
+ if (!userTheme) return null;
+
+ const SelectedIcon = ThemeIcons[userTheme];
+
+ return (
+
+ );
+}
diff --git a/app/components/layout/ThemeChanger.tsx b/app/components/layout/ThemeChanger.tsx
new file mode 100644
index 000000000..2daf1a394
--- /dev/null
+++ b/app/components/layout/ThemeChanger.tsx
@@ -0,0 +1,58 @@
+import { useTranslation } from "~/hooks/useTranslation";
+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";
+import { SelectedThemeIcon } from "./SelectedThemeIcon";
+
+const ThemeIcons = {
+ [Theme.LIGHT]: SunIcon,
+ [Theme.DARK]: MoonIcon,
+ auto: SunAndMoonIcon,
+};
+
+export function ThemeChanger({
+ children,
+ plain,
+}: {
+ children?: React.ReactNode;
+ plain?: boolean;
+}) {
+ const { userTheme, setUserTheme } = useTheme();
+ const { t } = useTranslation();
+
+ if (!userTheme) {
+ return null;
+ }
+
+ return (
+ }
+ triggerClassName={plain ? undefined : "layout__header__button"}
+ >
+
+ {(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
+ const Icon = ThemeIcons[theme];
+ const selected = userTheme === theme;
+ return (
+ }
+ // 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}`)}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx
index a10487002..6365e3add 100644
--- a/app/components/layout/index.tsx
+++ b/app/components/layout/index.tsx
@@ -8,6 +8,7 @@ import { Image } from "../Image";
import { SideNav } from "./SideNav";
import { UserItem } from "./UserItem";
import { LanguageChanger } from "./LanguageChanger";
+import { ThemeChanger } from "./ThemeChanger";
function useBreadcrumbs() {
const { t } = useTranslation();
@@ -73,6 +74,7 @@ export const Layout = React.memo(function Layout({
+
{!isCatchBoundary ? : null}
diff --git a/app/modules/theme/README.md b/app/modules/theme/README.md
new file mode 100644
index 000000000..7aff6875e
--- /dev/null
+++ b/app/modules/theme/README.md
@@ -0,0 +1,3 @@
+Implements dark mode for a Remix app.
+
+Based on https://github.com/remix-run/remix/blob/main/examples/dark-mode
diff --git a/app/modules/theme/action.server.ts b/app/modules/theme/action.server.ts
new file mode 100644
index 000000000..140ccc58e
--- /dev/null
+++ b/app/modules/theme/action.server.ts
@@ -0,0 +1,32 @@
+import type { ActionFunction } from "@remix-run/node";
+import { json } from "@remix-run/node";
+
+import { getThemeSession } from "./session.server";
+import { isTheme } from "./provider";
+
+export const action: ActionFunction = async ({ request }) => {
+ const themeSession = await getThemeSession(request);
+ const requestText = await request.text();
+ 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,
+ message: `theme value of ${theme ?? "null"} is not a valid theme`,
+ });
+ }
+
+ themeSession.setTheme(theme);
+ return json(
+ { success: true },
+ { headers: { "Set-Cookie": await themeSession.commit() } }
+ );
+};
diff --git a/app/modules/theme/index.ts b/app/modules/theme/index.ts
new file mode 100644
index 000000000..119743922
--- /dev/null
+++ b/app/modules/theme/index.ts
@@ -0,0 +1,2 @@
+export { Theme, ThemeHead, ThemeProvider, useTheme } from "./provider";
+export { action } from "./action.server";
diff --git a/app/modules/theme/provider.tsx b/app/modules/theme/provider.tsx
new file mode 100644
index 000000000..8f6fa1d00
--- /dev/null
+++ b/app/modules/theme/provider.tsx
@@ -0,0 +1,191 @@
+import { useFetcher } from "@remix-run/react";
+import { type ReactNode, useCallback } from "react";
+import { createContext, useContext, useEffect, useState } from "react";
+
+enum Theme {
+ DARK = "dark",
+ LIGHT = "light",
+}
+const themes: Array = Object.values(Theme);
+
+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(undefined);
+
+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,
+ themeSource,
+}: ThemeProviderProps) {
+ const [[theme, isAutoDetected], setThemeState] = useState<
+ [Theme, false] | [Theme | null, true]
+ >(() => {
+ if (themeSource === "static") {
+ return [specifiedTheme ?? Theme.DARK, false];
+ }
+
+ if (specifiedTheme) {
+ return [specifiedTheme, false];
+ }
+
+ /*
+ If we don't know a preferred user theme, we have to auto-detect it.
+ 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 persistThemeFetcher = useFetcher();
+ const persistTheme = persistThemeFetcher.submit;
+
+ const setUserTheme = useCallback(
+ (newTheme: Theme | "auto") => {
+ setThemeState(
+ newTheme === "auto" ? [getPreferredTheme(), true] : [newTheme, false]
+ );
+ persistTheme(
+ { theme: newTheme },
+ {
+ action: "theme",
+ method: "post",
+ }
+ );
+ },
+ [setThemeState, persistTheme]
+ );
+
+ useEffect(() => {
+ if (!isAutoDetected) {
+ return;
+ }
+
+ const mediaQuery = window.matchMedia(prefersLightMQ);
+ const handleChange = () => {
+ setThemeState([mediaQuery.matches ? Theme.LIGHT : Theme.DARK, true]);
+ };
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }, [isAutoDetected]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+// this is how I make certain we avoid a flash of the wrong theme. If you select
+// a theme, then I'll know what you want in the future and you'll not see this
+// script anymore.
+const clientThemeCode = `
+;(() => {
+ const theme = window.matchMedia(${JSON.stringify(prefersLightMQ)}).matches
+ ? 'light'
+ : 'dark';
+ const cl = document.documentElement.classList;
+ const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
+ if (themeAlreadyApplied) {
+ console.warn(
+ "Script is running but theme is already applied",
+ );
+ } else {
+ cl.add(theme);
+ }
+ const meta = document.querySelector('meta[name=color-scheme]');
+ if (meta) {
+ if (theme === 'dark') {
+ meta.content = 'dark light';
+ } else if (theme === 'light') {
+ meta.content = 'light dark';
+ }
+ } else {
+ console.warn(
+ "No meta tag",
+ );
+ }
+})();
+`;
+
+function ThemeHead() {
+ const { userTheme, metaColorScheme } = useTheme();
+ const [initialUserTheme] = useState(userTheme);
+
+ return (
+ <>
+ {/*
+ On the server, "theme" might be `null`, so clientThemeCode ensures that
+ this is correct before hydration.
+ */}
+
+ {/*
+ 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.
+ */}
+ {initialUserTheme === "auto" && (
+
+ )}
+ >
+ );
+}
+
+function useTheme() {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+}
+
+function isTheme(value: unknown): value is Theme {
+ return typeof value === "string" && themes.includes(value as Theme);
+}
+
+export { isTheme, Theme, ThemeHead, ThemeProvider, useTheme };
diff --git a/app/modules/theme/session.server.ts b/app/modules/theme/session.server.ts
new file mode 100644
index 000000000..67204ed0d
--- /dev/null
+++ b/app/modules/theme/session.server.ts
@@ -0,0 +1,39 @@
+import { createCookieSessionStorage } from "@remix-run/node";
+
+import { isTheme } from "./provider";
+import type { Theme } from "./provider";
+import invariant from "tiny-invariant";
+
+const TEN_YEARS_IN_SECONDS = 315_360_000;
+
+if (process.env.NODE_ENV === "production") {
+ invariant(process.env["SESSION_SECRET"], "SESSION_SECRET is required");
+}
+const sessionSecret = process.env["SESSION_SECRET"] ?? "secret";
+
+const themeStorage = createCookieSessionStorage({
+ cookie: {
+ name: "theme",
+ secure: process.env.NODE_ENV === "production",
+ secrets: [sessionSecret],
+ sameSite: "lax",
+ path: "/",
+ httpOnly: true,
+ maxAge: TEN_YEARS_IN_SECONDS,
+ },
+});
+
+async function getThemeSession(request: Request) {
+ const session = await themeStorage.getSession(request.headers.get("Cookie"));
+ return {
+ getTheme: () => {
+ const themeValue = session.get("theme");
+ return isTheme(themeValue) ? themeValue : null;
+ },
+ setTheme: (theme: Theme) => session.set("theme", theme),
+ commit: () => themeStorage.commitSession(session),
+ destroy: () => themeStorage.destroySession(session, { maxAge: 0 }),
+ };
+}
+
+export { getThemeSession };
diff --git a/app/root.tsx b/app/root.tsx
index b93f51c5a..f1b0dfd6c 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -36,6 +36,9 @@ import { ConditionalScrollRestoration } from "./components/ConditionalScrollRest
import { type SendouRouteHandle } from "~/utils/remix";
import generalI18next from "i18next";
import * as gtag from "~/utils/gtags.client";
+import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme";
+import { getThemeSession } from "./modules/theme/session.server";
+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
@@ -68,6 +71,7 @@ export const meta: MetaFunction = () => ({
export interface RootLoaderData {
locale: string;
+ theme: Theme | null;
patrons: FindAllPatrons;
gtmId?: string;
user?: Pick<
@@ -84,10 +88,12 @@ export interface RootLoaderData {
export const loader: LoaderFunction = async ({ request }) => {
const user = await getUser(request);
const locale = await i18next.getLocale(request);
+ const themeSession = await getThemeSession(request);
return json(
{
locale,
+ theme: themeSession.getTheme(),
patrons: db.users.findAllPatrons(),
gtmId: process.env["GTM_ID"],
user: user
@@ -120,6 +126,7 @@ function Document({
data?: RootLoaderData;
isCatchBoundary?: boolean;
}) {
+ const { htmlThemeClass } = useTheme();
const { i18n } = useTranslation();
const locale = data?.locale ?? DEFAULT_LANGUAGE;
@@ -128,10 +135,11 @@ function Document({
useTrackPageView();
return (
-
+
+
@@ -195,17 +203,24 @@ export default function App() {
const data = useLoaderData();
return (
-
-
-
+
+
+
+
+
);
}
export function CatchBoundary() {
return (
-
-
-
+
+
+
+
+
);
}
@@ -213,9 +228,11 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
console.error(error);
return (
-
-
-
+
+
+
+
+
);
};
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index c64c10a5e..ea884014f 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -24,12 +24,16 @@ import { LogOutIcon } from "~/components/icons/LogOut";
import { LogInButtonContainer } from "~/components/layout/LogInButtonContainer";
import { LogInIcon } from "~/components/icons/LogIn";
import * as React from "react";
+import { ThemeChanger } from "~/components/layout/ThemeChanger";
+import { SelectedThemeIcon } from "~/components/layout/SelectedThemeIcon";
+import { useTheme } from "~/modules/theme";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: styles }];
};
export default function FrontPage() {
+ const { userTheme } = useTheme();
const [filters, setFilters] = React.useState<[string, string]>(
navItems[0]?.filters as [string, string]
);
@@ -52,7 +56,14 @@ export default function FrontPage() {
{selectedLanguage?.name ?? ""}
-
+
+
+
+
+
+
+ {t(`common:theme.${userTheme ?? "auto"}`)}
+
{user ? (
diff --git a/app/routes/theme.ts b/app/routes/theme.ts
new file mode 100644
index 000000000..2270b3e33
--- /dev/null
+++ b/app/routes/theme.ts
@@ -0,0 +1,5 @@
+import { type LoaderFunction, redirect } from "@remix-run/node";
+
+export { action } from "~/modules/theme";
+
+export const loader: LoaderFunction = () => redirect("/", { status: 404 });
diff --git a/app/styles/common.css b/app/styles/common.css
index 70f70403f..c5f6e3004 100644
--- a/app/styles/common.css
+++ b/app/styles/common.css
@@ -483,7 +483,7 @@ dialog::backdrop {
.popover-content {
z-index: 1;
max-width: 20rem;
- padding: var(--s-4);
+ padding: var(--s-2);
border: 2px solid var(--border);
border-radius: var(--rounded);
background-color: var(--bg-darker);
diff --git a/app/styles/layout.css b/app/styles/layout.css
index 5082069c2..cc1a4a519 100644
--- a/app/styles/layout.css
+++ b/app/styles/layout.css
@@ -44,8 +44,8 @@
align-items: center;
justify-content: space-between;
border-bottom: 1.5px solid var(--border);
- -webkit-backdrop-filter: blur(10px) brightness(75%);
- backdrop-filter: blur(10px) brightness(75%);
+ -webkit-backdrop-filter: var(--backdrop-filter);
+ backdrop-filter: var(--backdrop-filter);
background-color: transparent;
font-weight: bold;
padding-block: var(--s-2);
diff --git a/app/styles/vars.css b/app/styles/vars.css
index 218a78518..756640888 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -1,4 +1,88 @@
-:root {
+html {
+ --bg: #ebebf0;
+ --bg-darker: #f8f8f8;
+ --bg-lighter: #ffffff;
+ --bg-lightest: #ffffff;
+ --bg-lighter-transparent: hsla(225deg 100% 88% / 50%);
+ --bg-darker-very-transparent: rgba(187, 208, 219, 0.295);
+ --bg-darker-transparent: hsla(202deg 90% 90% / 65%);
+ --bg-ability: rgb(3 6 7);
+ --bg-badge: #000;
+ --bg-mode-active: rgba(213, 42, 190, 0.234);
+ --abilities-button-bg: rgb(22, 23, 51);
+ --badge-text: rgb(255 255 255 / 95%);
+ --border: rgb(217, 218, 234);
+ --button-text: rgba(0, 0, 0, 0.85);
+ --button-text-transparent: rgb(0 0 0 / 65%);
+ --text: rgb(0 0 0 / 95%);
+ --black-text: rgb(0 0 0 / 95%);
+ --text-lighter: rgb(75 75 75 / 95%);
+ --divider: #f5a2c8;
+ --theme-error: rgb(199 13 6);
+ --theme-error-transparent: rgba(199 13 6 / 55%);
+ --theme-warning: #c9c900;
+ --theme-warning-transparent: #c9c90052;
+ --theme-success: #00a514;
+ --theme-success-transparent: #00a51452;
+ --theme-info: #1fb0d0;
+ --theme-info-transparent: #1fb0d052;
+ --theme-informative-yellow: #b09901;
+ --theme-informative-red: #9d0404;
+ --theme-informative-blue: #007f9c;
+ --theme-informative-green: #017a0f;
+ --theme: #f73e8b;
+ --theme-transparent: #f3a0c386;
+ --theme-very-transparent: #f3a0c341;
+ --theme-secondary: rgb(63 58 255);
+ --backdrop-filter: blur(10px) brightness(95%);
+ --rounded: 16px;
+ --rounded-full: 200px;
+ --rounded-sm: 10px;
+ --fonts-xl: 1.5rem;
+ --fonts-lg: 1.2rem;
+ --fonts-md: 1rem;
+ --fonts-sm: 0.92rem;
+ --fonts-xs: 0.8rem;
+ --fonts-xxs: 0.7rem;
+ --fonts-xxxs: 0.6rem;
+ --fonts-xxxxs: 0.5rem;
+ --extra-bold: 700;
+ --bold: 600;
+ --semi-bold: 500;
+ --body: 400;
+ --s-1: 0.25rem;
+ --s-1-5: 0.375rem;
+ --s-2: 0.5rem;
+ --s-2-5: 0.625rem;
+ --s-3: 0.75rem;
+ --s-4: 1rem;
+ --s-5: 1.25rem;
+ --s-6: 1.5rem;
+ --s-7: 1.75rem;
+ --s-8: 2rem;
+ --s-9: 2.25rem;
+ --s-10: 2.5rem;
+ --s-11: 2.75rem;
+ --s-12: 3rem;
+ --s-14: 3.5rem;
+ --s-16: 4rem;
+ --s-20: 5rem;
+ --s-24: 6rem;
+ --s-28: 7rem;
+ --s-32: 8rem;
+ --s-40: 10rem;
+ --s-48: 12rem;
+ --s-56: 14rem;
+ --s-64: 16rem;
+ --s-72: 18rem;
+ --s-80: 20rem;
+ --s-96: 2rem;
+ --sparse: 0.4px;
+ --label-margin: var(--s-1);
+ --inactive-image-filter: grayscale(100%) brightness(30%);
+}
+
+html.dark {
--bg: #02011e;
--bg-darker: #0a092d;
--bg-lighter: rgb(169 138 255 / 10%);
@@ -34,52 +118,8 @@
--theme-vibrant: hsl(255deg 78% 65%);
--theme-transparent: #ffc6de52;
--theme-secondary: rgb(239 229 83);
+ --backdrop-filter: blur(10px) brightness(75%);
--inactive-image-filter: grayscale(100%) brightness(130%);
- --rounded: 16px;
- --rounded-full: 200px;
- --rounded-sm: 10px;
- --fonts-xl: 1.5rem;
- --fonts-lg: 1.2rem;
- --fonts-md: 1rem;
- --fonts-sm: 0.92rem;
- --fonts-xs: 0.8rem;
- --fonts-xxs: 0.7rem;
- --fonts-xxxs: 0.6rem;
- --fonts-xxxxs: 0.5rem;
- --extra-bold: 700;
- --bold: 700;
- --semi-bold: 600;
- --body: 400;
- --s-1: 0.25rem;
- --s-1-5: 0.375rem;
- --s-2: 0.5rem;
- --s-2-5: 0.625rem;
- --s-3: 0.75rem;
- --s-4: 1rem;
- --s-5: 1.25rem;
- --s-6: 1.5rem;
- --s-7: 1.75rem;
- --s-8: 2rem;
- --s-9: 2.25rem;
- --s-10: 2.5rem;
- --s-11: 2.75rem;
- --s-12: 3rem;
- --s-14: 3.5rem;
- --s-16: 4rem;
- --s-20: 5rem;
- --s-24: 6rem;
- --s-26: 6.5rem;
- --s-28: 7rem;
- --s-32: 8rem;
- --s-40: 10rem;
- --s-48: 12rem;
- --s-56: 14rem;
- --s-64: 16rem;
- --s-72: 18rem;
- --s-80: 20rem;
- --s-96: 2rem;
- --sparse: 0.4px;
- --label-margin: var(--s-1);
}
html.dark .light-mode-only {
diff --git a/public/locales/da/common.json b/public/locales/da/common.json
index 83f4594ab..0e1c3230c 100644
--- a/public/locales/da/common.json
+++ b/public/locales/da/common.json
@@ -102,6 +102,10 @@
"weapon.category.STRINGERS": "Stringers/Buer",
"weapon.category.SPLATANAS": "Splatanas/Splatanaer",
+ "theme.light": "Lyst",
+ "theme.dark": "Mørkt",
+ "theme.auto": "Automatisk",
+
"plans.poweredBy": "Tegneprogrammet er leveret af {{name}}",
"articles.by": "af {{author}}",
diff --git a/public/locales/de/common.json b/public/locales/de/common.json
index 83578886c..e1009a062 100644
--- a/public/locales/de/common.json
+++ b/public/locales/de/common.json
@@ -106,6 +106,10 @@
"weapon.category.STRINGERS": "Stringer",
"weapon.category.SPLATANAS": "Splatanas",
+ "theme.light": "Hell",
+ "theme.dark": "Dunkel",
+ "theme.auto": "Auto",
+
"plans.poweredBy": "Ermöglicht durch {{name}}",
"articles.by": "von {{author}}",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index 95d7cb204..04e4be532 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -110,5 +110,9 @@
"articles.by": "by {{author}}",
+ "theme.light": "Light",
+ "theme.dark": "Dark",
+ "theme.auto": "Auto",
+
"websiteSubtitle": "Competitive Splatoon Hub"
}
diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json
index bf8e343d2..8de9a595c 100644
--- a/public/locales/ja/common.json
+++ b/public/locales/ja/common.json
@@ -102,5 +102,9 @@
"weapon.category.STRINGERS": "ストリンガー",
"weapon.category.SPLATANAS": "ワイパー",
+ "theme.light": "Light",
+ "theme.dark": "Dark",
+ "theme.auto": "自動",
+
"websiteSubtitle": "Competitive Splatoon Hub"
}
diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json
index a56a1fcb0..b50f04623 100644
--- a/public/locales/ru/common.json
+++ b/public/locales/ru/common.json
@@ -106,6 +106,10 @@
"weapon.category.STRINGERS": "Тетиваторы",
"weapon.category.SPLATANAS": "Сплат-катаны",
+ "theme.light": "Светлая",
+ "theme.dark": "Тёмная",
+ "theme.auto": "Системная",
+
"plans.poweredBy": "При поддержке {{name}}",
"articles.by": "от {{author}}",
diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json
index 67cb3cb2e..31d9a1a59 100644
--- a/public/locales/zh/common.json
+++ b/public/locales/zh/common.json
@@ -106,5 +106,9 @@
"plans.poweredBy": "Powered by {{name}}",
+ "theme.light": "浅色模式",
+ "theme.dark": "深色模式",
+ "theme.auto": "自动",
+
"websiteSubtitle": "斯普拉遁竞技中心"
}