mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 08:38:42 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
3
apps/web-react/app/features/theme/core/README.md
Normal file
3
apps/web-react/app/features/theme/core/README.md
Normal 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
|
||||
140
apps/web-react/app/features/theme/core/provider.tsx
Normal file
140
apps/web-react/app/features/theme/core/provider.tsx
Normal 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 };
|
||||
@@ -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 };
|
||||
37
apps/web-react/app/features/theme/routes/theme.ts
Normal file
37
apps/web-react/app/features/theme/routes/theme.ts
Normal 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 });
|
||||
36
apps/web-react/app/features/theme/theme-constants.ts
Normal file
36
apps/web-react/app/features/theme/theme-constants.ts
Normal 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];
|
||||
Reference in New Issue
Block a user