mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
18
app/components/icons/Moon.tsx
Normal file
18
app/components/icons/Moon.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function MoonIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
app/components/icons/Sun.tsx
Normal file
18
app/components/icons/Sun.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function SunIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { Image } from "../Image";
|
||||
import { Footer } from "./Footer";
|
||||
import type { RootLoaderData } from "~/root";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Theme, useTheme } from "~/modules/theme";
|
||||
import { MoonIcon } from "../icons/Moon";
|
||||
import { SunIcon } from "../icons/Sun";
|
||||
|
||||
export const Layout = React.memo(function Layout({
|
||||
children,
|
||||
@@ -18,6 +21,7 @@ export const Layout = React.memo(function Layout({
|
||||
patrons?: RootLoaderData["patrons"];
|
||||
isCatchBoundary?: boolean;
|
||||
}) {
|
||||
const [, setTheme] = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
@@ -26,6 +30,12 @@ export const Layout = React.memo(function Layout({
|
||||
location.pathname.includes(navItem.name)
|
||||
);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prevTheme) =>
|
||||
prevTheme === Theme.LIGHT ? Theme.DARK : Theme.LIGHT
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout__container">
|
||||
<header className="layout__header">
|
||||
@@ -44,6 +54,13 @@ export const Layout = React.memo(function Layout({
|
||||
<div />
|
||||
)}
|
||||
<div className="layout__header__right-container">
|
||||
<button
|
||||
className="layout__header__color-mode-button"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<SunIcon className="light-mode-only layout__header__color-mode-button__icon" />
|
||||
<MoonIcon className="dark-mode-only layout__header__color-mode-button__icon" />
|
||||
</button>
|
||||
{!isCatchBoundary ? <UserItem /> : null}
|
||||
<HamburgerButton
|
||||
expanded={menuOpen}
|
||||
|
||||
3
app/modules/theme/README.md
Normal file
3
app/modules/theme/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
|
||||
25
app/modules/theme/action.server.ts
Normal file
25
app/modules/theme/action.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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 (!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() } }
|
||||
);
|
||||
};
|
||||
2
app/modules/theme/index.ts
Normal file
2
app/modules/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Theme, ThemeHead, ThemeProvider, useTheme } from "./provider";
|
||||
export { action } from "./action.server";
|
||||
164
app/modules/theme/provider.tsx
Normal file
164
app/modules/theme/provider.tsx
Normal file
@@ -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<Theme> = Object.values(Theme);
|
||||
|
||||
type ThemeContextType = [Theme | null, Dispatch<SetStateAction<Theme | null>>];
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(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<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.
|
||||
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 (
|
||||
<ThemeContext.Provider value={[theme, setTheme]}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
*/}
|
||||
<meta
|
||||
name="color-scheme"
|
||||
content={theme === "light" ? "light dark" : "dark light"}
|
||||
/>
|
||||
{/*
|
||||
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 : (
|
||||
<>
|
||||
<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 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
35
app/modules/theme/session.server.ts
Normal file
35
app/modules/theme/session.server.ts
Normal file
@@ -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 };
|
||||
26
app/root.tsx
26
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<RootLoaderData>({
|
||||
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 (
|
||||
<html lang={locale} dir={i18n.dir()}>
|
||||
<html lang={locale} dir={i18n.dir()} className={theme ?? ""}>
|
||||
<head>
|
||||
<Meta />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<Links />
|
||||
<ThemeHead ssrTheme={Boolean(data?.theme)} />
|
||||
</head>
|
||||
<body>
|
||||
<React.StrictMode>
|
||||
@@ -118,16 +124,20 @@ export default function App() {
|
||||
const data = useLoaderData<RootLoaderData>();
|
||||
|
||||
return (
|
||||
<Document data={data}>
|
||||
<Outlet />
|
||||
</Document>
|
||||
<ThemeProvider specifiedTheme={data?.theme ?? null}>
|
||||
<Document data={data}>
|
||||
<Outlet />
|
||||
</Document>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
return (
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
<ThemeProvider specifiedTheme={Theme.DARK}>
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/routes/theme.ts
Normal file
5
app/routes/theme.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { type LoaderFunction, redirect } from "@remix-run/node";
|
||||
|
||||
export { action } from "~/modules/theme";
|
||||
|
||||
export const loader: LoaderFunction = () => redirect("/", { status: 404 });
|
||||
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user