mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: support toggling light or dark color schemes
Fixes HP-375.
This commit is contained in:
+50
-2
@@ -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 (
|
||||
<header
|
||||
@@ -135,6 +165,24 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{colorSchemes.map(({ value, label, icon: Icon }) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
onClick={() =>
|
||||
submit(
|
||||
{ colorScheme: value, returnTo },
|
||||
{ action: "/api/color-scheme", method: "POST" },
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Icon className="size-4" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{currentColorScheme === value && <Check className="size-4" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => submit({}, { action: "/logout", method: "POST" })}
|
||||
|
||||
+26
-2
@@ -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 (
|
||||
<LiveDataProvider>
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
className={
|
||||
loaderData?.colorScheme === "dark"
|
||||
? "dark"
|
||||
: loaderData?.colorScheme === "light"
|
||||
? "light"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+42
-1
@@ -2,6 +2,20 @@
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
/* Dark mode: respect an explicit `.dark` / `.light` class on <html>, 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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user