diff --git a/apps/designer/app/designer/designer.tsx b/apps/designer/app/designer/designer.tsx index 9405d0bf0d..9be7dbb5e3 100644 --- a/apps/designer/app/designer/designer.tsx +++ b/apps/designer/app/designer/designer.tsx @@ -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(); diff --git a/apps/designer/app/designer/features/topbar/menu/menu.tsx b/apps/designer/app/designer/features/topbar/menu/menu.tsx index 20383b883a..98a08dea96 100644 --- a/apps/designer/app/designer/features/topbar/menu/menu.tsx +++ b/apps/designer/app/designer/features/topbar/menu/menu.tsx @@ -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 = { + light: "Light", + dark: "Dark", + system: "System theme", + }; + + const settings = Object.keys(labels) as Array; + + return ( + + + Theme + + + + {settings.map((setting) => ( + { + setThemeSetting(setting); + }} + > + {labels[setting]} + + ))} + + + ); +}; + type MenuProps = { config: Config; publish: Publish; @@ -163,6 +206,7 @@ export const Menu = ({ config, publish }: MenuProps) => { + { diff --git a/apps/designer/app/designer/features/workspace/canvas-tools/outline/label.tsx b/apps/designer/app/designer/features/workspace/canvas-tools/outline/label.tsx index b05b80a5bf..5df30b9c7c 100644 --- a/apps/designer/app/designer/features/workspace/canvas-tools/outline/label.tsx +++ b/apps/designer/app/designer/features/workspace/canvas-tools/outline/label.tsx @@ -50,7 +50,6 @@ const LabelContainer = styled( minWidth: "$6", whiteSpace: "nowrap", backgroundColor: "$blue9", - pointerEvents: "auto", }, { variants: { diff --git a/apps/designer/app/designer/shared/client-settings/index.ts b/apps/designer/app/designer/shared/client-settings/index.ts new file mode 100644 index 0000000000..dcf101b0c6 --- /dev/null +++ b/apps/designer/app/designer/shared/client-settings/index.ts @@ -0,0 +1 @@ +export * from "./settings"; diff --git a/apps/designer/app/designer/shared/client-settings/settings.ts b/apps/designer/app/designer/shared/client-settings/settings.ts new file mode 100644 index 0000000000..7c93a8fdc4 --- /dev/null +++ b/apps/designer/app/designer/shared/client-settings/settings.ts @@ -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>; + +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); + } + ); +}; diff --git a/apps/designer/app/designer/shared/client-settings/values.ts b/apps/designer/app/designer/shared/client-settings/values.ts new file mode 100644 index 0000000000..4862f44df8 --- /dev/null +++ b/apps/designer/app/designer/shared/client-settings/values.ts @@ -0,0 +1,5 @@ +// @todo delete example once we have other values +export const example = { + defaultValue: "example", + values: ["one", "two", "three"], +} as const; diff --git a/apps/designer/app/entry.server.tsx b/apps/designer/app/entry.server.tsx index 7ee8c468dd..05a2120187 100644 --- a/apps/designer/app/entry.server.tsx +++ b/apps/designer/app/entry.server.tsx @@ -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("" + markup, { status: responseStatusCode, diff --git a/apps/designer/app/routes/dashboard.tsx b/apps/designer/app/routes/dashboard.tsx index 936c0f8323..20bd4e9ce0 100644 --- a/apps/designer/app/routes/dashboard.tsx +++ b/apps/designer/app/routes/dashboard.tsx @@ -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" }; diff --git a/apps/designer/app/routes/designer.tsx b/apps/designer/app/routes/designer.tsx index 932f3a8cac..7097e1b5cf 100644 --- a/apps/designer/app/routes/designer.tsx +++ b/apps/designer/app/routes/designer.tsx @@ -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" }; diff --git a/apps/designer/app/routes/designer/$id.tsx b/apps/designer/app/routes/designer/$id.tsx index 3babd37a9a..e76fddaf30 100644 --- a/apps/designer/app/routes/designer/$id.tsx +++ b/apps/designer/app/routes/designer/$id.tsx @@ -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) { diff --git a/apps/designer/app/routes/login.tsx b/apps/designer/app/routes/login.tsx index 2c24216657..99d9febb01 100644 --- a/apps/designer/app/routes/login.tsx +++ b/apps/designer/app/routes/login.tsx @@ -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" }; diff --git a/apps/designer/app/routes/rest/theme.$setting.ts b/apps/designer/app/routes/rest/theme.$setting.ts new file mode 100644 index 0000000000..eae98b218b --- /dev/null +++ b/apps/designer/app/routes/rest/theme.$setting.ts @@ -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 }); +}; diff --git a/apps/designer/app/shared/design-system/components/dropdown-menu.tsx b/apps/designer/app/shared/design-system/components/dropdown-menu.tsx index 458e3975bc..6e5b38d3c1 100644 --- a/apps/designer/app/shared/design-system/components/dropdown-menu.tsx +++ b/apps/designer/app/shared/design-system/components/dropdown-menu.tsx @@ -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, diff --git a/apps/designer/app/shared/design-system/stitches.config.ts b/apps/designer/app/shared/design-system/stitches.config.ts index 3d8a0e3dc0..9f5d9140fa 100644 --- a/apps/designer/app/shared/design-system/stitches.config.ts +++ b/apps/designer/app/shared/design-system/stitches.config.ts @@ -433,7 +433,7 @@ export const { export type CSS = Stitches.CSS; -export const darkTheme = createTheme("dark-theme", { +export const darkTheme = createTheme({ colors: { ...grayDark, ...mauveDark, diff --git a/apps/designer/app/shared/documents/designer.tsx b/apps/designer/app/shared/documents/designer.tsx index 95605b1931..859680748d 100644 --- a/apps/designer/app/shared/documents/designer.tsx +++ b/apps/designer/app/shared/documents/designer.tsx @@ -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 ( - + diff --git a/apps/designer/app/shared/feature-flags/feature.ts b/apps/designer/app/shared/feature-flags/feature.ts index c000c73826..185e07aabb 100644 --- a/apps/designer/app/shared/feature-flags/feature.ts +++ b/apps/designer/app/shared/feature-flags/feature.ts @@ -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; }; diff --git a/apps/designer/app/shared/theme/index.ts b/apps/designer/app/shared/theme/index.ts new file mode 100644 index 0000000000..b47713ebdd --- /dev/null +++ b/apps/designer/app/shared/theme/index.ts @@ -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"; diff --git a/apps/designer/app/shared/theme/theme.server.ts b/apps/designer/app/shared/theme/theme.server.ts new file mode 100644 index 0000000000..8252b8b984 --- /dev/null +++ b/apps/designer/app/shared/theme/theme.server.ts @@ -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 }; +}; diff --git a/apps/designer/app/shared/theme/theme.ts b/apps/designer/app/shared/theme/theme.ts new file mode 100644 index 0000000000..5771192ef3 --- /dev/null +++ b/apps/designer/app/shared/theme/theme.ts @@ -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; +}; diff --git a/apps/designer/app/shared/theme/types.ts b/apps/designer/app/shared/theme/types.ts new file mode 100644 index 0000000000..30553c6b07 --- /dev/null +++ b/apps/designer/app/shared/theme/types.ts @@ -0,0 +1,2 @@ +export type ColorScheme = "dark" | "light"; +export type ThemeSetting = ColorScheme | "system";