mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
Merge pull request #120 from webstudio-is/theme-switcher
Theme switcher
This commit is contained in:
20 files changed
+279
-16
No files matched your search
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export type ColorScheme = "dark" | "light";
|
||||
export type ThemeSetting = ColorScheme | "system";
|
||||
Reference in new issue
Block a user