diff --git a/app/layout/header.tsx b/app/layout/header.tsx index 4de8af0..ccbabca 100644 --- a/app/layout/header.tsx +++ b/app/layout/header.tsx @@ -1,5 +1,17 @@ -import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react"; -import { NavLink, useSubmit } from "react-router"; +import { + Check, + CircleQuestionMark, + CircleUser, + Globe, + Lock, + Monitor, + Moon, + Server, + Settings, + Sun, + Users, +} from "lucide-react"; +import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router"; import Link from "~/components/link"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu"; @@ -7,6 +19,7 @@ import logoBg from "~/logo/dark-bg.svg"; import logoDark from "~/logo/dark.svg"; import logoLight from "~/logo/light.svg"; import cn from "~/utils/cn"; +import type { ColorScheme } from "~/utils/color-scheme"; export interface HeaderProps { user: { @@ -35,9 +48,26 @@ const tabs = [ { to: "/settings", icon: Settings, label: "Settings", key: "settings" }, ] as const; +const colorSchemes = [ + { value: "system", label: "System", icon: Monitor }, + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, +] as const satisfies ReadonlyArray<{ + value: ColorScheme; + label: string; + icon: typeof Monitor; +}>; + export default function Header({ user, access, configAvailable }: HeaderProps) { const submit = useSubmit(); const showTabs = access.ui; + const rootRoute = useRoute("root"); + const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system"; + // useLocation returns the path with the basename already stripped, which is + // what `redirect()` expects — react-router re-applies the basename when + // following the redirect on the client. + const location = useLocation(); + const returnTo = location.pathname + location.search; return (
+ {colorSchemes.map(({ value, label, icon: Icon }) => ( + + submit( + { colorScheme: value, returnTo }, + { action: "/api/color-scheme", method: "POST" }, + ) + } + > +
+ + {label} + {currentColorScheme === value && } +
+
+ ))} + submit({}, { action: "/logout", method: "POST" })} diff --git a/app/root.tsx b/app/root.tsx index bda7d39..5b57f39 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,5 +1,12 @@ import type { MetaFunction } from "react-router"; -import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; +import { + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, + unstable_useRoute as useRoute, +} from "react-router"; import { LiveDataProvider } from "~/utils/live-data"; import ToastProvider from "~/utils/toast-provider"; @@ -9,6 +16,7 @@ import { ErrorBanner } from "./components/error-banner"; import "@fontsource-variable/inter/opsz.css"; import "./tailwind.css"; +import { getColorScheme } from "./utils/color-scheme"; export const meta: MetaFunction = () => [ { title: "Headplane" }, @@ -18,13 +26,29 @@ export const meta: MetaFunction = () => [ }, ]; +export async function loader({ request }: Route.LoaderArgs) { + const colorScheme = await getColorScheme(request); + return { colorScheme }; +} + export function Layout({ children }: { readonly children: React.ReactNode }) { + const { loaderData } = useRoute("root"); + // LiveDataProvider is wrapped at the top level since dialogs and things // that control its state are usually open in portal containers which // are not a part of the normal React tree. return ( - + diff --git a/app/routes.ts b/app/routes.ts index 7490570..5f8a6d0 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -5,7 +5,10 @@ export default [ route("/healthz", "routes/util/healthz.ts"), // API Routes - ...prefix("/api", [route("/info", "routes/util/info.ts")]), + ...prefix("/api", [ + route("/info", "routes/util/info.ts"), + route("/color-scheme", "routes/util/color-scheme.ts"), + ]), ...prefix("/events", [route("/live", "routes/util/live.ts")]), // Authentication Routes diff --git a/app/routes/util/color-scheme.ts b/app/routes/util/color-scheme.ts new file mode 100644 index 0000000..5327add --- /dev/null +++ b/app/routes/util/color-scheme.ts @@ -0,0 +1,34 @@ +import { data, redirect } from "react-router"; + +import { isValidColorScheme, setColorScheme } from "~/utils/color-scheme"; + +import type { Route } from "./+types/color-scheme"; + +export async function action({ request }: Route.ActionArgs) { + const formData = await request.formData(); + const colorScheme = formData.get("colorScheme"); + const returnTo = safeRedirect(formData.get("returnTo")); + + if (!colorScheme || !isValidColorScheme(colorScheme)) { + throw data("Bad Request", { status: 400 }); + } + + return redirect(returnTo, { + headers: { + "Set-Cookie": await setColorScheme(colorScheme), + }, + }); +} + +// Stolen from react-router thanks! +function safeRedirect(to: FormDataEntryValue | null) { + if (!to || typeof to !== "string") { + return "/"; + } + + if (!to.startsWith("/") || to.startsWith("//")) { + return "/"; + } + + return to; +} diff --git a/app/tailwind.css b/app/tailwind.css index 9d8fb3f..fcafb77 100644 --- a/app/tailwind.css +++ b/app/tailwind.css @@ -2,6 +2,20 @@ @plugin "tailwindcss-animate"; +/* Dark mode: respect an explicit `.dark` / `.light` class on , and fall + * back to the user's OS preference when neither is set (i.e. "system"). */ +@custom-variant dark { + &:where(.dark, .dark *) { + @slot; + } + @media (prefers-color-scheme: dark) { + &:where(html:not(.light):not(.dark)), + &:where(html:not(.light):not(.dark) *) { + @slot; + } + } +} + @theme { --blur-xs: 2px; @@ -106,8 +120,35 @@ body { --cm-bracket: var(--color-mist-500); } +/* CodeMirror dark token vars — applied for explicit .dark or system preference + * when no explicit class is set. Mirrors the dark variant above. */ +.dark { + --cm-bg: var(--color-mist-950); + --cm-fg: var(--color-mist-100); + --cm-caret: var(--color-mist-100); + --cm-selection: var(--color-indigo-900); + --cm-selection-match: var(--color-indigo-950); + --cm-line-highlight: oklch(18% 0.006 224 / 0.5); + --cm-gutter-bg: var(--color-mist-950); + --cm-gutter-fg: var(--color-mist-500); + --cm-gutter-fg-active: var(--color-mist-300); + --cm-gutter-border: var(--color-mist-800); + --cm-comment: var(--color-mist-400); + --cm-keyword: var(--color-purple-400); + --cm-string: var(--color-emerald-400); + --cm-type: var(--color-indigo-400); + --cm-definition: var(--color-blue-400); + --cm-name: var(--color-mist-200); + --cm-variable: var(--color-cyan-400); + --cm-property: var(--color-violet-400); + --cm-atom: var(--color-orange-400); + --cm-number: var(--color-orange-400); + --cm-link: var(--color-blue-400); + --cm-bracket: var(--color-mist-400); +} + @media (prefers-color-scheme: dark) { - :root { + html:not(.light):not(.dark) { --cm-bg: var(--color-mist-950); --cm-fg: var(--color-mist-100); --cm-caret: var(--color-mist-100); diff --git a/app/utils/color-scheme.ts b/app/utils/color-scheme.ts new file mode 100644 index 0000000..f3f99f8 --- /dev/null +++ b/app/utils/color-scheme.ts @@ -0,0 +1,26 @@ +import { createCookie } from "react-router"; + +export type ColorScheme = "dark" | "light" | "system"; + +let cookie = createCookie("color_scheme", { + maxAge: 34560000, + sameSite: "lax", +}); + +export function isValidColorScheme(val: unknown): val is ColorScheme { + return typeof val === "string" && ["dark", "light", "system"].includes(val); +} + +export async function getColorScheme(request: Request) { + const header = request.headers.get("Cookie"); + const vals = await cookie.parse(header); + return ["dark", "light", "system"].includes(vals?.colorScheme) ? vals.colorScheme : "system"; +} + +export function setColorScheme(colorScheme: ColorScheme) { + if (colorScheme === "system") { + return cookie.serialize({}, { expires: new Date(0), maxAge: 0 }); + } + + return cookie.serialize({ colorScheme }); +}