@@ -44,6 +54,13 @@ export const Layout = React.memo(function Layout({
)}
+
{!isCatchBoundary ? : null}
{
+ const themeSession = await getThemeSession(request);
+ const requestText = await request.text();
+ const form = new URLSearchParams(requestText);
+ const theme = form.get("theme");
+
+ 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..5e2283c84
--- /dev/null
+++ b/app/modules/theme/provider.tsx
@@ -0,0 +1,164 @@
+import { useFetcher } from "@remix-run/react";
+import type { Dispatch, ReactNode, SetStateAction } from "react";
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+
+enum Theme {
+ DARK = "dark",
+ LIGHT = "light",
+}
+const themes: Array = Object.values(Theme);
+
+type ThemeContextType = [Theme | null, Dispatch>];
+
+const ThemeContext = createContext(undefined);
+
+const prefersLightMQ = "(prefers-color-scheme: light)";
+const getPreferredTheme = () =>
+ window.matchMedia(prefersLightMQ).matches ? Theme.LIGHT : Theme.DARK;
+
+function ThemeProvider({
+ children,
+ specifiedTheme,
+}: {
+ children: ReactNode;
+ specifiedTheme: Theme | null;
+}) {
+ const [theme, setTheme] = useState(() => {
+ // 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.
+ if (specifiedTheme) {
+ if (themes.includes(specifiedTheme)) {
+ return specifiedTheme;
+ } else {
+ return null;
+ }
+ }
+
+ // 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;
+ }
+
+ return getPreferredTheme();
+ });
+
+ const persistTheme = useFetcher();
+ // TODO: remove this when persistTheme is memoized properly
+ const persistThemeRef = useRef(persistTheme);
+ useEffect(() => {
+ persistThemeRef.current = persistTheme;
+ }, [persistTheme]);
+
+ const mountRun = useRef(false);
+
+ useEffect(() => {
+ if (!mountRun.current) {
+ mountRun.current = true;
+ return;
+ }
+ if (!theme) {
+ 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);
+ };
+ mediaQuery.addEventListener("change", handleChange);
+ return () => mediaQuery.removeEventListener("change", handleChange);
+ }, []);
+
+ 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({ ssrTheme }: { ssrTheme: boolean }) {
+ const [theme] = useTheme();
+
+ 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 the server then we don't need
+ to do fancy tricks prior to hydration to make things match.
+ */}
+ {ssrTheme ? null : (
+ <>
+
+ >
+ )}
+ >
+ );
+}
+
+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..0311135d9
--- /dev/null
+++ b/app/modules/theme/session.server.ts
@@ -0,0 +1,35 @@
+import { createCookieSessionStorage } from "@remix-run/node";
+
+import { isTheme } from "./provider";
+import type { Theme } from "./provider";
+import invariant from "tiny-invariant";
+
+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: true,
+ secrets: [sessionSecret],
+ sameSite: "lax",
+ path: "/",
+ httpOnly: true,
+ },
+});
+
+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),
+ };
+}
+
+export { getThemeSession };
diff --git a/app/root.tsx b/app/root.tsx
index 3e0c91641..25a551ea2 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -29,6 +29,8 @@ import { getUser } from "./modules/auth";
import { DEFAULT_LANGUAGE, i18next } from "./modules/i18n";
import { useChangeLanguage } from "remix-i18next";
import { useTranslation } from "react-i18next";
+import { Theme, ThemeHead, useTheme, ThemeProvider } from "./modules/theme";
+import { getThemeSession } from "./modules/theme/session.server";
export const unstable_shouldReload: ShouldReloadFunction = () => false;
@@ -51,6 +53,7 @@ export const meta: MetaFunction = () => ({
export interface RootLoaderData {
locale: string;
+ theme: Theme | null;
patrons: FindAllPatrons;
user?: Pick<
UserWithPlusTier,
@@ -61,9 +64,11 @@ 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(),
user: user
? {
@@ -87,16 +92,17 @@ function Document({
children: React.ReactNode;
data?: RootLoaderData;
}) {
+ const [theme] = useTheme();
const { i18n } = useTranslation();
const locale = data?.locale ?? DEFAULT_LANGUAGE;
useChangeLanguage(locale);
return (
-
+
-
+
@@ -118,16 +124,20 @@ export default function App() {
const data = useLoaderData();
return (
-
-
-
+
+
+
+
+
);
}
export function CatchBoundary() {
return (
-
-
-
+
+
+
+
+
);
}
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/layout.css b/app/styles/layout.css
index a3302ef9b..d3e9faf95 100644
--- a/app/styles/layout.css
+++ b/app/styles/layout.css
@@ -36,6 +36,22 @@
justify-self: flex-end;
}
+.layout__header__color-mode-button {
+ width: var(--item-size);
+ height: var(--item-size);
+ padding: 0.25rem;
+ border: 2px solid;
+ border-color: var(--theme-transparent-vibrant);
+ background-color: transparent;
+ border-radius: 50%;
+ color: inherit;
+ cursor: pointer;
+}
+
+.layout__header__color-mode-button__icon {
+ width: 1.5rem;
+}
+
.main {
width: 100%;
max-width: 48rem;
diff --git a/app/styles/vars.css b/app/styles/vars.css
index 928f29a2e..e9bf5c662 100644
--- a/app/styles/vars.css
+++ b/app/styles/vars.css
@@ -1,4 +1,4 @@
-:root {
+html {
--bg: hsl(202deg 100% 96%);
--bg-darker: hsl(202deg 90% 90%);
--bg-lighter: hsl(225deg 100% 88%);
@@ -73,36 +73,42 @@
--label-margin: var(--s-1);
}
-@media (prefers-color-scheme: dark) {
- :root {
- --bg: hsl(237.3deg 42.3% 30.6%);
- --bg-darker: hsl(237.3deg 42.3% 26.6%);
- --bg-lighter: hsl(237.3deg 42.3% 35.6%);
- --bg-lighter-transparent: rgb(64 67 108 / 50%);
- --bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
- --bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
- --bg-ability: rgb(17 19 43);
- --bg-badge: #000;
- --border: hsl(237.3deg 42.3% 45.6%);
- --button-text: rgb(0 0 0 / 85%);
- --button-text-transparent: rgb(0 0 0 / 65%);
- --text: rgb(255 255 255 / 95%);
- --black-text: rgb(0 0 0 / 95%);
- --text-lighter: rgb(215 214 255 / 80%);
- --theme-error: rgb(219 70 65);
- --theme-error-transparent: rgba(219 70 65 / 75%);
- --theme-warning: #f5f587;
- --theme-success: #a3ffae;
- --theme-success-transparent: #a3ffae52;
- --theme-info: #87cddc;
- --theme-info-transparent: #87cddc52;
- --theme: hsl(255deg 66.7% 75%);
- --theme-vibrant: hsl(255deg 78% 65%);
- --theme-transparent: hsl(255deg 66.7% 75% / 40%);
- --theme-transparent-vibrant: hsl(255deg 78% 65% / 54%);
- --theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%);
- --theme-secondary: hsl(85deg 66.7% 55.3%);
- }
+html.dark {
+ --bg: hsl(237.3deg 42.3% 30.6%);
+ --bg-darker: hsl(237.3deg 42.3% 26.6%);
+ --bg-lighter: hsl(237.3deg 42.3% 35.6%);
+ --bg-lighter-transparent: rgb(64 67 108 / 50%);
+ --bg-darker-very-transparent: hsla(237.3deg 42.3% 26.6% / 50%);
+ --bg-darker-transparent: hsla(237.3deg 42.3% 26.6% / 90%);
+ --bg-ability: rgb(17 19 43);
+ --bg-badge: #000;
+ --border: hsl(237.3deg 42.3% 45.6%);
+ --button-text: rgb(0 0 0 / 85%);
+ --button-text-transparent: rgb(0 0 0 / 65%);
+ --text: rgb(255 255 255 / 95%);
+ --black-text: rgb(0 0 0 / 95%);
+ --text-lighter: rgb(215 214 255 / 80%);
+ --theme-error: rgb(219 70 65);
+ --theme-error-transparent: rgba(219 70 65 / 75%);
+ --theme-warning: #f5f587;
+ --theme-success: #a3ffae;
+ --theme-success-transparent: #a3ffae52;
+ --theme-info: #87cddc;
+ --theme-info-transparent: #87cddc52;
+ --theme: hsl(255deg 66.7% 75%);
+ --theme-vibrant: hsl(255deg 78% 65%);
+ --theme-transparent: hsl(255deg 66.7% 75% / 40%);
+ --theme-transparent-vibrant: hsl(255deg 78% 65% / 54%);
+ --theme-semi-transparent-vibrant: hsl(255deg 78% 65% / 75%);
+ --theme-secondary: hsl(85deg 66.7% 55.3%);
+}
+
+html.dark .light-mode-only {
+ display: none;
+}
+
+html.light .dark-mode-only {
+ display: none;
}
/* xs: "(min-width: 480px)",