Merge pull request #120 from webstudio-is/theme-switcher

Theme switcher
This commit is contained in:
Oleg Isonen authored and GitHub committed 2022-07-13 11:45:59 +02:00
commit 011a18f5ca
20 files changed
+279 -16

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ import {
import { usePublishShortcuts } from "./shared/shortcuts";
import { type SyncStatus } from "~/shared/sync";
import { useIsPreviewMode, useRootInstance } from "~/shared/nano-states";
import { useSubscribeClientSetting } from "./shared/client-settings";
export const links = () => {
return [
@@ -177,6 +178,7 @@ export const Designer = ({ config, project }: DesignerProps) => {
useSubscribeSelectedInstanceData();
useSubscribeHoveredInstanceData();
useSubscribeBreakpoints();
useSubscribeClientSetting();
const [publish, publishRef] = usePublish();
const [isPreviewMode] = useIsPreviewMode();
const [isDragging, setIsDragging] = useIsDragging();
@@ -5,18 +5,26 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuTriggerItem,
DropdownMenuCheckboxItem,
DropdownMenuArrow,
DropdownMenuSeparator,
IconButton,
Box,
} from "~/shared/design-system";
import { HamburgerMenuIcon } from "~/shared/icons";
import { HamburgerMenuIcon, ChevronRightIcon } from "~/shared/icons";
import type { Config } from "~/config";
import { ShortcutHint } from "./shortcut-hint";
import {
useIsShareDialogOpen,
useIsPublishDialogOpen,
} from "~/designer/shared/nano-states";
import { isFeatureEnabled } from "~/shared/feature-flags";
import {
getThemeSetting,
setThemeSetting,
type ThemeSetting,
} from "~/shared/theme";
const menuItemCss = {
display: "flex",
@@ -26,6 +34,41 @@ const menuItemCss = {
minWidth: 140,
};
const ThemeMenuItem = () => {
if (isFeatureEnabled("theme") === false) return null;
const currentSetting = getThemeSetting();
const labels: Record<ThemeSetting, string> = {
light: "Light",
dark: "Dark",
system: "System theme",
};
const settings = Object.keys(labels) as Array<ThemeSetting>;
return (
<DropdownMenu>
<DropdownMenuTriggerItem>
Theme
<ChevronRightIcon />
</DropdownMenuTriggerItem>
<DropdownMenuContent>
{settings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting}
checked={currentSetting === setting}
css={menuItemCss}
onSelect={() => {
setThemeSetting(setting);
}}
>
{labels[setting]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
type MenuProps = {
config: Config;
publish: Publish;
@@ -163,6 +206,7 @@ export const Menu = ({ config, publish }: MenuProps) => {
<ShortcutHint value={["cmd", "-"]} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<ThemeMenuItem />
<DropdownMenuItem
css={menuItemCss}
onSelect={() => {
@@ -50,7 +50,6 @@ const LabelContainer = styled(
minWidth: "$6",
whiteSpace: "nowrap",
backgroundColor: "$blue9",
pointerEvents: "auto",
},
{
variants: {
@@ -0,0 +1 @@
export * from "./settings";
@@ -0,0 +1,66 @@
import { useSubscribe } from "@webstudio-is/sdk";
import { sentryException } from "~/shared/sentry";
import * as values from "./values";
type Name = keyof typeof values;
type Value = typeof values[Name]["values"][number];
type Settings = Partial<Record<Name, Value>>;
const namespace = "__webstudio_user_settings__";
const read = (): Settings => {
let settingsString;
try {
settingsString = localStorage.getItem(namespace);
} catch (_error) {
// We don't need to handle this one.
}
if (settingsString == null) return {};
try {
return JSON.parse(settingsString);
} catch (error) {
if (error instanceof Error) {
sentryException({
message: "Bad user settings in local storage",
extra: {
error: error.message,
},
});
}
}
return {};
};
const write = (settings: Settings) => {
localStorage.setItem(namespace, JSON.stringify(settings));
};
/**
* Get a value from local storage or a default.
*/
export const getSetting = (name: Name) => {
const settings = read();
const validValues = values[name].values;
const value = settings[name];
const isValidValue = value !== undefined && validValues.includes(value);
if (isValidValue) return value;
return values[name].defaultValue;
};
export const setSetting = (name: Name, value: Value) => {
const settings = read();
const validValues = values[name].values;
const isValidValue = validValues.includes(value);
if (isValidValue) write({ ...settings, [name]: value });
};
export const useSubscribeClientSetting = () => {
useSubscribe<"setClientSetting", { name: Name; value: Value }>(
"setClientSetting",
(payload) => {
setSetting(payload.name, payload.value);
}
);
};
@@ -0,0 +1,5 @@
// @todo delete example once we have other values
export const example = {
defaultValue: "example",
values: ["one", "two", "three"],
} as const;
+1
View File
@@ -23,6 +23,7 @@ export default function handleRequest(
markup = insertCriticalCss(markup, request.url);
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set("Accept-CH", "Sec-CH-Prefers-Color-Scheme");
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
+7 -3
View File
@@ -1,10 +1,14 @@
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { Designer } from "~/shared/documents/designer";
import env from "~/env.server";
import { getThemeData } from "~/shared/theme";
export const loader: LoaderFunction = () => ({
env,
});
export const loader: LoaderFunction = async ({ request }) => {
return {
env,
theme: await getThemeData(request),
};
};
export const meta: MetaFunction = () => {
return { title: "Webstudio Dashboard" };
+7 -3
View File
@@ -1,10 +1,14 @@
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
import { Designer } from "~/shared/documents/designer";
import env from "~/env.server";
import { getThemeData } from "~/shared/theme";
export const loader: LoaderFunction = () => ({
env,
});
export const loader: LoaderFunction = async ({ request }) => {
return {
env,
theme: await getThemeData(request),
};
};
export const meta: MetaFunction = () => {
return { title: "Webstudio" };
+7 -2
View File
@@ -1,13 +1,18 @@
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunction } from "@remix-run/node";
import { LoaderFunction, redirect } from "@remix-run/node";
import type { Project } from "@webstudio-is/sdk";
import { Designer, links } from "~/designer";
import * as db from "~/shared/db";
import config from "~/config";
import env from "~/env.server";
import { authenticator } from "~/services/auth.server";
export { links };
export const loader: LoaderFunction = async ({ params }) => {
export const loader: LoaderFunction = async ({ request, params }) => {
const user = await authenticator.isAuthenticated(request);
if (!user) {
return redirect(config.loginPath);
}
if (params.id === undefined) throw new Error("Project id undefined");
const project = await db.project.loadById(params.id);
if (project === null) {
+7 -3
View File
@@ -1,10 +1,14 @@
import { LoaderFunction, MetaFunction } from "@remix-run/node";
import { Designer } from "~/shared/documents/designer";
import env from "~/env.server";
import { getThemeData } from "~/shared/theme";
export const loader: LoaderFunction = () => ({
env,
});
export const loader: LoaderFunction = async ({ request }) => {
return {
env,
theme: await getThemeData(request),
};
};
export const meta: MetaFunction = () => {
return { title: "Webstudio Login" };
@@ -0,0 +1,10 @@
import { json, type LoaderFunction } from "@remix-run/node";
import { themeCookieParser } from "~/shared/theme";
export const loader: LoaderFunction = async ({ params }) => {
const { setting } = params;
const headers = {
"Set-Cookie": await themeCookieParser.serialize(setting),
};
return json({ setting }, { headers });
};
@@ -12,6 +12,12 @@ export const DropdownMenuArrow = styled(DropdownMenuPrimitive.Arrow, {
fill: "$panel",
});
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuTriggerItem = styled(
DropdownMenuPrimitive.TriggerItem,
itemCss
);
export const DropdownMenuContent = styled(
DropdownMenuPrimitive.Content,
menuCss,
@@ -433,7 +433,7 @@ export const {
export type CSS = Stitches.CSS<typeof config>;
export const darkTheme = createTheme("dark-theme", {
export const darkTheme = createTheme({
colors: {
...grayDark,
...mauveDark,
@@ -1,10 +1,12 @@
import { Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react";
import { CriticalCss } from "@webstudio-is/sdk";
import { Env } from "~/shared/env";
import { useThemeProps } from "~/shared/theme";
export const Designer = () => {
const themeProps = useThemeProps();
return (
<html lang="en">
<html lang="en" {...themeProps}>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -28,5 +28,7 @@ export const isFeatureEnabled = (name: Name): boolean => {
const defaultValue = flags[name];
const envValue = parse(env.FEATURE).includes(name);
const localValue = readLocal().includes(name);
return localValue ?? envValue ?? defaultValue;
// Any source can enable feature, first `true` value will result in enabling a feature.
// This also means you can't disable a feature if its already enabled in default value.
return localValue || envValue || defaultValue;
};
+6
View File
@@ -0,0 +1,6 @@
// This article explains the foundation of the technique we are using
// https://rossmoody.com/writing/remix-stitches
export * from "./theme";
export * from "./theme.server";
export type { ThemeSetting } from "./types";
@@ -0,0 +1,16 @@
import { createCookie } from "@remix-run/node";
import type { ColorScheme, ThemeSetting } from "./types";
const cookieNamespace = "theme";
export const themeCookieParser = createCookie(cookieNamespace);
export const getThemeData = async (
request: Request
): Promise<{ system?: ColorScheme; setting?: ThemeSetting }> => {
const system = (request.headers.get("Sec-CH-Prefers-Color-Scheme") ??
undefined) as ColorScheme | undefined;
const setting: ThemeSetting =
(await themeCookieParser.parse(request.headers.get("Cookie"))) ?? undefined;
return { system, setting };
};
+84
View File
@@ -0,0 +1,84 @@
import { useLoaderData } from "@remix-run/react";
import { darkTheme } from "~/shared/design-system";
import type { ColorScheme, ThemeSetting } from "./types";
// User selected theme setting.
let setting: ThemeSetting = "dark";
// Current systeme theme.
let system: ColorScheme;
/**
* Subscribes color scheme change event and rerenders
*/
const subscribeSystemTheme = () => {
if (typeof matchMedia === "undefined") return;
const query = matchMedia("(prefers-color-scheme: light)");
const queryColorScheme = (query: MediaQueryList | MediaQueryListEvent) =>
query.matches ? "light" : "dark";
system = queryColorScheme(query);
query.addEventListener("change", (queryEvent) => {
system = queryColorScheme(queryEvent);
renderThemeProps();
});
};
subscribeSystemTheme();
// @todo todo switch to light by default once ready
const defaultTheme = "dark";
/**
* Logic we use to decide depending on user setting which color scheme to use.
*/
const getColorScheme = (): ColorScheme => {
if (setting === "system") {
return system || defaultTheme;
}
return setting || defaultTheme;
};
/**
* When switching theme we need to apply the class/style manually.
*/
const renderThemeProps = () => {
const theme = getColorScheme();
const root = document.documentElement;
theme === "dark"
? root.classList.add(darkTheme.className)
: root.classList.remove(darkTheme.className);
root.style.colorScheme = theme;
root.dataset.theme = theme;
};
/**
* Apply immediately and set the cookie on the server.
*/
export const setThemeSetting = (nextSetting: ThemeSetting) => {
setting = nextSetting;
renderThemeProps();
fetch(`/rest/theme/${setting}`);
};
/**
* Returns the class and style to applied to some root node serverside and during hydration.
*/
export const useThemeProps = () => {
const data = useLoaderData();
if (data.theme.setting) setting = data.theme.setting;
if (data.theme.system) system = data.theme.system;
const theme = getColorScheme();
return {
className: theme === "dark" ? darkTheme.className : undefined,
style: { colorScheme: theme },
"data-theme": theme,
};
};
/**
* Currently selected by user theme setting.
*/
export const getThemeSetting = (): ThemeSetting => {
return setting;
};
+2
View File
@@ -0,0 +1,2 @@
export type ColorScheme = "dark" | "light";
export type ThemeSetting = ColorScheme | "system";