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,3 @@
Implements dark mode for a Remix app.
Based on https://github.com/remix-run/remix/blob/main/examples/dark-mode

View File

@@ -0,0 +1,140 @@
import {
createContext,
type ReactNode,
useContext,
useState,
useSyncExternalStore,
} from "react";
import { useFetcher } from "react-router";
const Theme = {
DARK: "dark",
LIGHT: "light",
} as const;
type Theme = (typeof Theme)[keyof typeof Theme];
const themes = Object.values(Theme);
type ThemeContextType = {
htmlThemeClass: Theme | "";
metaColorScheme: "light dark" | "dark light";
userTheme: Theme | "auto" | null;
setUserTheme: (newTheme: Theme | "auto") => void;
};
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const PREFERS_LIGHT_MQ = "(prefers-color-scheme: light)";
function getSystemTheme() {
return window.matchMedia(PREFERS_LIGHT_MQ).matches ? Theme.LIGHT : Theme.DARK;
}
function subscribeToSystemTheme(callback: () => void) {
const mediaQuery = window.matchMedia(PREFERS_LIGHT_MQ);
mediaQuery.addEventListener("change", callback);
return () => mediaQuery.removeEventListener("change", callback);
}
function useSystemTheme() {
return useSyncExternalStore(
subscribeToSystemTheme,
getSystemTheme,
() => null,
);
}
type ThemeProviderProps = {
children: ReactNode;
specifiedTheme: Theme | null;
themeSource: "user-preference" | "static";
};
function colorScheme(theme: Theme | "") {
return theme === Theme.LIGHT ? "light dark" : "dark light";
}
function ThemeProvider({
children,
specifiedTheme,
themeSource,
}: ThemeProviderProps) {
const isStatic = themeSource === "static";
const [userPreference, setUserPreference] = useState<Theme | "auto">(
isStatic ? (specifiedTheme ?? Theme.DARK) : (specifiedTheme ?? "auto"),
);
const systemTheme = useSystemTheme();
const persistThemeFetcher = useFetcher();
const resolvedTheme: Theme | "" = isStatic
? (specifiedTheme ?? Theme.DARK)
: userPreference === "auto"
? (systemTheme ?? "")
: userPreference;
const handleSetUserTheme = (newTheme: Theme | "auto") => {
setUserPreference(newTheme);
persistThemeFetcher.submit(
{ theme: newTheme },
{ action: "theme", method: "post" },
);
};
return (
<ThemeContext.Provider
value={{
htmlThemeClass: resolvedTheme,
metaColorScheme: colorScheme(resolvedTheme),
userTheme: isStatic ? null : userPreference,
setUserTheme: handleSetUserTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
const CLIENT_THEME_SCRIPT = `
;(() => {
const theme = window.matchMedia(${JSON.stringify(PREFERS_LIGHT_MQ)}).matches
? 'light'
: 'dark';
const cl = document.documentElement.classList;
const themeAlreadyApplied = cl.contains('light') || cl.contains('dark');
if (!themeAlreadyApplied) {
cl.add(theme);
}
const meta = document.querySelector('meta[name=color-scheme]');
if (meta) {
meta.content = theme === 'dark' ? 'dark light' : 'light dark';
}
})();
`;
function ThemeHead() {
const { userTheme, metaColorScheme } = useTheme();
const [initialUserTheme] = useState(userTheme);
return (
<>
<meta name="color-scheme" content={metaColorScheme} />
{initialUserTheme === "auto" ? (
<script dangerouslySetInnerHTML={{ __html: CLIENT_THEME_SCRIPT }} />
) : 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 };

View File

@@ -0,0 +1,34 @@
import { createCookieSessionStorage } from "react-router";
import { ServerConfig } from "~/config.server";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
import type { Theme } from "./provider";
import { isTheme } from "./provider";
const TEN_YEARS_IN_SECONDS = 315_360_000;
const themeStorage = createCookieSessionStorage({
cookie: {
name: "theme",
secure: ServerConfig.isProduction && !IS_E2E_TEST_RUN,
secrets: [ServerConfig.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 };

View File

@@ -0,0 +1,37 @@
import {
type ActionFunction,
data,
type LoaderFunction,
redirect,
} from "react-router";
import { isTheme } from "../core/provider";
import { getThemeSession } from "../core/theme-session.server";
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 data(
{ success: true },
{ headers: { "Set-Cookie": await themeSession.destroy() } },
);
}
if (!isTheme(theme)) {
return {
success: false,
message: `theme value of ${theme ?? "null"} is not a valid theme`,
};
}
themeSession.setTheme(theme);
return data(
{ success: true },
{ headers: { "Set-Cookie": await themeSession.commit() } },
);
};
export const loader: LoaderFunction = () => redirect("/", { status: 404 });

View File

@@ -0,0 +1,36 @@
/** CSS variables a supporter can override with their own custom theme. */
export const CUSTOM_THEME_VARS = [
"--_base-h",
"--_base-c-0",
"--_base-c-1",
"--_base-c-2",
"--_base-c-3",
"--_base-c-4",
"--_base-c-5",
"--_base-c-6",
"--_base-c-7",
"--_acc-h",
"--_acc-c-0",
"--_acc-c-1",
"--_acc-c-2",
"--_acc-c-3",
"--_acc-c-4",
"--_acc-c-5",
"--_second-h",
"--_second-c-0",
"--_second-c-1",
"--_second-c-2",
"--_second-c-3",
"--_second-c-4",
"--_second-c-5",
"--_chat-h",
"--_radius-box",
"--_radius-field",
"--_radius-selector",
"--_border-width",
"--_size-field",
"--_size-selector",
"--_size-spacing",
] as const;
export type CustomThemeVar = (typeof CUSTOM_THEME_VARS)[number];