mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Restore light mode
This commit is contained in:
@@ -36,9 +36,7 @@ export function FormWithConfirm({
|
||||
<Button form={id} variant="destructive" type="submit">
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={closeDialog}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
32
app/components/icons/Moon.tsx
Normal file
32
app/components/icons/Moon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export function MoonIcon({
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
33
app/components/icons/Sun.tsx
Normal file
33
app/components/icons/Sun.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
export function SunIcon({
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
32
app/components/icons/SunAndMoon.tsx
Normal file
32
app/components/icons/SunAndMoon.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
export function SunAndMoonIcon({
|
||||
className,
|
||||
alt,
|
||||
size,
|
||||
}: {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-hidden={alt === ""}
|
||||
aria-label={alt !== "" ? alt : undefined}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
{alt !== "" && <title>{alt}</title>}
|
||||
<path
|
||||
d="m19.921 16.252-1.4411-1.4841m-8.6467-8.9048-1.4411-1.4841m5.8557-0.26889 0.03042-2.0685m4.3307 3.9505 1.4841-1.4411m0.2689 5.8557 2.0685 0.03042m-10.747 2.2802c-3.2025-3.2981 1.7446-8.1018 4.9471-4.8037 3.2012 3.298-1.7459 8.1018-4.9471 4.8037zm3.233 5.4901a7.0662 7.0662 0 0 1-2.7676 0.28142c-3.8978-0.37316-6.7547-3.8351-6.3816-7.7328 0.092161-0.96268 0.3725-1.8613 0.80141-2.6639a7.0917 7.0917 0 0 0-4.9653 6.1002c-0.37316 3.8978 2.4838 7.3597 6.3816 7.7328a7.0917 7.0917 0 0 0 6.9314-3.7177z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
app/components/layout/SelectedThemeIcon.tsx
Normal file
28
app/components/layout/SelectedThemeIcon.tsx
Normal file
@@ -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 (
|
||||
<SelectedIcon
|
||||
alt={t("header.theme")}
|
||||
className="layout__header__button__icon"
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
app/components/layout/ThemeChanger.tsx
Normal file
58
app/components/layout/ThemeChanger.tsx
Normal file
@@ -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 (
|
||||
<Popover
|
||||
buttonChildren={children ?? <SelectedThemeIcon />}
|
||||
triggerClassName={plain ? undefined : "layout__header__button"}
|
||||
>
|
||||
<div className="layout__user-popover">
|
||||
{(["auto", Theme.DARK, Theme.LIGHT] as const).map((theme) => {
|
||||
const Icon = ThemeIcons[theme];
|
||||
const selected = userTheme === theme;
|
||||
return (
|
||||
<Button
|
||||
variant="minimal"
|
||||
key={theme}
|
||||
tiny
|
||||
icon={<Icon alt="" />}
|
||||
// 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}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
<div className="layout__header__right-container">
|
||||
<LanguageChanger />
|
||||
<ThemeChanger />
|
||||
{!isCatchBoundary ? <UserItem /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
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
|
||||
32
app/modules/theme/action.server.ts
Normal file
32
app/modules/theme/action.server.ts
Normal file
@@ -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() } }
|
||||
);
|
||||
};
|
||||
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";
|
||||
191
app/modules/theme/provider.tsx
Normal file
191
app/modules/theme/provider.tsx
Normal file
@@ -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<Theme> = 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<ThemeContextType | undefined>(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 (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
// Gets corrected by clientThemeCode if set to "" during SSR
|
||||
htmlThemeClass: theme ?? "",
|
||||
// Gets corrected by clientThemeCode if set to wrong value during SSR
|
||||
metaColorScheme: theme === "light" ? "light dark" : "dark light",
|
||||
userTheme:
|
||||
themeSource === "static" ? null : isAutoDetected ? "auto" : theme!,
|
||||
setUserTheme,
|
||||
}}
|
||||
>
|
||||
{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() {
|
||||
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.
|
||||
*/}
|
||||
<meta name="color-scheme" content={metaColorScheme} />
|
||||
{/*
|
||||
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" && (
|
||||
<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 };
|
||||
39
app/modules/theme/session.server.ts
Normal file
39
app/modules/theme/session.server.ts
Normal file
@@ -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 };
|
||||
37
app/root.tsx
37
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<RootLoaderData>(
|
||||
{
|
||||
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 (
|
||||
<html lang={locale} dir={i18n.dir()}>
|
||||
<html lang={locale} dir={i18n.dir()} className={htmlThemeClass}>
|
||||
<head>
|
||||
<Meta />
|
||||
<Links />
|
||||
<ThemeHead />
|
||||
<link rel="manifest" href="/app.webmanifest" />
|
||||
<PWALinks />
|
||||
<Fonts />
|
||||
@@ -195,17 +203,24 @@ export default function App() {
|
||||
const data = useLoaderData<RootLoaderData>();
|
||||
|
||||
return (
|
||||
<Document data={data}>
|
||||
<Outlet />
|
||||
</Document>
|
||||
<ThemeProvider
|
||||
specifiedTheme={isTheme(data.theme) ? data.theme : null}
|
||||
themeSource="user-preference"
|
||||
>
|
||||
<Document data={data}>
|
||||
<Outlet />
|
||||
</Document>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function CatchBoundary() {
|
||||
return (
|
||||
<Document isCatchBoundary>
|
||||
<Catcher />
|
||||
</Document>
|
||||
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
|
||||
<Document isCatchBoundary>
|
||||
<Catcher />
|
||||
</Document>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,9 +228,11 @@ export const ErrorBoundary: ErrorBoundaryComponent = ({ error }) => {
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
<ThemeProvider themeSource="static" specifiedTheme={Theme.DARK}>
|
||||
<Document>
|
||||
<Catcher />
|
||||
</Document>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ?? ""}
|
||||
</div>
|
||||
|
||||
<div className="front__nav-item round" />
|
||||
<div className="front__nav-item round">
|
||||
<ThemeChanger plain>
|
||||
<div className="front__nav-image-container round">
|
||||
<SelectedThemeIcon size={28} />
|
||||
</div>
|
||||
</ThemeChanger>
|
||||
{t(`common:theme.${userTheme ?? "auto"}`)}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<Link to={userPage(user)} className="front__nav-item round">
|
||||
|
||||
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 });
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -110,5 +110,9 @@
|
||||
|
||||
"articles.by": "by {{author}}",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.auto": "Auto",
|
||||
|
||||
"websiteSubtitle": "Competitive Splatoon Hub"
|
||||
}
|
||||
|
||||
@@ -102,5 +102,9 @@
|
||||
"weapon.category.STRINGERS": "ストリンガー",
|
||||
"weapon.category.SPLATANAS": "ワイパー",
|
||||
|
||||
"theme.light": "Light",
|
||||
"theme.dark": "Dark",
|
||||
"theme.auto": "自動",
|
||||
|
||||
"websiteSubtitle": "Competitive Splatoon Hub"
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@
|
||||
"weapon.category.STRINGERS": "Тетиваторы",
|
||||
"weapon.category.SPLATANAS": "Сплат-катаны",
|
||||
|
||||
"theme.light": "Светлая",
|
||||
"theme.dark": "Тёмная",
|
||||
"theme.auto": "Системная",
|
||||
|
||||
"plans.poweredBy": "При поддержке {{name}}",
|
||||
|
||||
"articles.by": "от {{author}}",
|
||||
|
||||
@@ -106,5 +106,9 @@
|
||||
|
||||
"plans.poweredBy": "Powered by {{name}}",
|
||||
|
||||
"theme.light": "浅色模式",
|
||||
"theme.dark": "深色模式",
|
||||
"theme.auto": "自动",
|
||||
|
||||
"websiteSubtitle": "斯普拉遁竞技中心"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user