diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index b42f5fe4a..83b56b02c 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -5,9 +5,9 @@ import Initials from "./Initials"; export interface IAvatar { avatarUrl: string | null; - color: string; - initial: string; - id: string; + color?: string; + initial?: string; + id?: string; } type Props = { @@ -61,7 +61,7 @@ const IconWrapper = styled.div` position: absolute; bottom: -2px; right: -2px; - background: ${(props) => props.theme.primary}; + background: ${(props) => props.theme.accent}; border: 2px solid ${(props) => props.theme.background}; border-radius: 100%; width: 20px; diff --git a/app/components/Badge.ts b/app/components/Badge.ts index 414784281..e79f2b1dc 100644 --- a/app/components/Badge.ts +++ b/app/components/Badge.ts @@ -5,7 +5,7 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>` margin-left: 10px; padding: 1px 5px 2px; background-color: ${({ yellow, primary, theme }) => - yellow ? theme.yellow : primary ? theme.primary : "transparent"}; + yellow ? theme.yellow : primary ? theme.accent : "transparent"}; color: ${({ primary, yellow, theme }) => primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary}; border: 1px solid diff --git a/app/components/Button.tsx b/app/components/Button.tsx index f494025ec..a176124ee 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -1,6 +1,6 @@ import { LocationDescriptor } from "history"; import { ExpandedIcon } from "outline-icons"; -import { darken, lighten } from "polished"; +import { darken, lighten, transparentize } from "polished"; import * as React from "react"; import styled from "styled-components"; import ActionButton, { @@ -22,8 +22,8 @@ const RealButton = styled(ActionButton)` margin: 0; padding: 0; border: 0; - background: ${(props) => props.theme.buttonBackground}; - color: ${(props) => props.theme.buttonText}; + background: ${(props) => props.theme.accent}; + color: ${(props) => props.theme.accentText}; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px; border-radius: 4px; font-size: 14px; @@ -51,14 +51,14 @@ const RealButton = styled(ActionButton)` &:hover:not(:disabled), &[aria-expanded="true"] { - background: ${(props) => darken(0.05, props.theme.buttonBackground)}; + background: ${(props) => darken(0.05, props.theme.accent)}; } &:disabled { cursor: default; pointer-events: none; - color: ${(props) => props.theme.white50}; - background: ${(props) => lighten(0.2, props.theme.buttonBackground)}; + color: ${(props) => transparentize(0.5, props.theme.accentText)}; + background: ${(props) => lighten(0.2, props.theme.accent)}; svg { fill: ${(props) => props.theme.white50}; diff --git a/app/components/CircularProgressBar.tsx b/app/components/CircularProgressBar.tsx index 23d85b143..f8ec93d8a 100644 --- a/app/components/CircularProgressBar.tsx +++ b/app/components/CircularProgressBar.tsx @@ -63,7 +63,7 @@ const CircularProgressBar = ({ {percentage > 0 && ( diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx index 704e9563a..79fdf459c 100644 --- a/app/components/ContextMenu/MenuItem.tsx +++ b/app/components/ContextMenu/MenuItem.tsx @@ -147,13 +147,13 @@ export const MenuAnchorCSS = css` &:hover, &:focus, &.focus-visible { - color: ${props.theme.white}; - background: ${props.dangerous ? props.theme.danger : props.theme.primary}; + color: ${props.theme.accentText}; + background: ${props.dangerous ? props.theme.danger : props.theme.accent}; box-shadow: none; cursor: var(--pointer); svg { - fill: ${props.theme.white}; + fill: ${props.theme.accentText}; } } } @@ -163,13 +163,13 @@ export const MenuAnchorCSS = css` props.$active && !props.disabled && ` - color: ${props.theme.white}; - background: ${props.dangerous ? props.theme.danger : props.theme.primary}; + color: ${props.theme.accentText}; + background: ${props.dangerous ? props.theme.danger : props.theme.accent}; box-shadow: none; cursor: var(--pointer); svg { - fill: ${props.theme.white}; + fill: ${props.theme.accentText}; } `} diff --git a/app/components/DocumentExplorerNode.tsx b/app/components/DocumentExplorerNode.tsx index 1431661b9..0f45c0b2b 100644 --- a/app/components/DocumentExplorerNode.tsx +++ b/app/components/DocumentExplorerNode.tsx @@ -117,7 +117,7 @@ export const Node = styled.span<{ ${(props) => props.selected && ` - background: ${props.theme.primary}; + background: ${props.theme.accent}; color: ${props.theme.white}; svg { diff --git a/app/components/DocumentTasks.tsx b/app/components/DocumentTasks.tsx index 174087304..7d0258711 100644 --- a/app/components/DocumentTasks.tsx +++ b/app/components/DocumentTasks.tsx @@ -44,7 +44,7 @@ function DocumentTasks({ document }: Props) { <> {completed === total ? ( diff --git a/app/components/IconPicker.tsx b/app/components/IconPicker.tsx index 252d1ad05..04399b0d8 100644 --- a/app/components/IconPicker.tsx +++ b/app/components/IconPicker.tsx @@ -46,6 +46,7 @@ import Flex from "~/components/Flex"; import { LabelText } from "~/components/Input"; import NudeButton from "~/components/NudeButton"; import Text from "~/components/Text"; +import DelayedMount from "./DelayedMount"; const style = { width: 30, @@ -263,7 +264,13 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) { })} - {t("Loading")}…}> + + {t("Loading")}… + + } + > onChange(color.hex, icon)} @@ -328,10 +335,6 @@ const IconButton = styled(NudeButton)` height: 30px; `; -const Loading = styled(Text)` - padding: 16px; -`; - const ColorPicker = styled(TwitterPicker)` box-shadow: none !important; background: transparent !important; diff --git a/app/components/InputColor.tsx b/app/components/InputColor.tsx new file mode 100644 index 000000000..282812eb3 --- /dev/null +++ b/app/components/InputColor.tsx @@ -0,0 +1,87 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { MenuButton, useMenuState } from "reakit/Menu"; +import styled from "styled-components"; +import ContextMenu from "./ContextMenu"; +import DelayedMount from "./DelayedMount"; +import Input, { Props as InputProps } from "./Input"; +import NudeButton from "./NudeButton"; +import Relative from "./Sidebar/components/Relative"; +import Text from "./Text"; + +type Props = Omit & { + value: string | undefined; + onChange: (value: string) => void; +}; + +const InputColor: React.FC = ({ value, onChange, ...rest }) => { + const { t } = useTranslation(); + const menu = useMenuState({ + modal: true, + placement: "bottom-end", + }); + + return ( + + onChange(event.target.value)} + placeholder="#" + maxLength={7} + {...rest} + /> + + {(props) => ( + + )} + + + + {t("Loading")}… + + } + > + onChange(color.hex)} + /> + + + + ); +}; + +const SwatchButton = styled(NudeButton)<{ $background: string | undefined }>` + background: ${(props) => props.$background}; + border: 1px solid ${(props) => props.theme.inputBorder}; + border-radius: 50%; + position: absolute; + bottom: 20px; + right: 6px; +`; + +const ColorPicker = React.lazy( + () => import("react-color/lib/components/chrome/Chrome") +); + +const StyledColorPicker = styled(ColorPicker)` + background: inherit !important; + box-shadow: none !important; + border: 0 !important; + border-radius: 0 !important; + user-select: none; + + input { + user-select: text; + color: ${(props) => props.theme.text} !important; + } +`; + +export default InputColor; diff --git a/app/components/InputSelect.tsx b/app/components/InputSelect.tsx index 613ac0936..b9ce01766 100644 --- a/app/components/InputSelect.tsx +++ b/app/components/InputSelect.tsx @@ -276,7 +276,7 @@ const Positioner = styled(Position)` ${StyledSelectOption} { &[aria-selected="true"] { color: ${(props) => props.theme.white}; - background: ${(props) => props.theme.primary}; + background: ${(props) => props.theme.accent}; box-shadow: none; cursor: var(--pointer); diff --git a/app/components/List/Item.tsx b/app/components/List/Item.tsx index f34853e2a..6b42e27c0 100644 --- a/app/components/List/Item.tsx +++ b/app/components/List/Item.tsx @@ -52,7 +52,7 @@ const ListItem = ( $border={border} $small={small} activeStyle={{ - background: theme.primary, + background: theme.accent, }} {...rest} as={NavLink} diff --git a/app/components/LoadingIndicator/LoadingIndicatorBar.tsx b/app/components/LoadingIndicator/LoadingIndicatorBar.tsx index a3e01532c..26957c78f 100644 --- a/app/components/LoadingIndicator/LoadingIndicatorBar.tsx +++ b/app/components/LoadingIndicator/LoadingIndicatorBar.tsx @@ -28,7 +28,7 @@ const Container = styled.div` const Loader = styled.div` width: 100%; height: 2px; - background-color: ${(props) => props.theme.primary}; + background-color: ${(props) => props.theme.accent}; `; export default LoadingIndicatorBar; diff --git a/app/components/Sidebar/Shared.tsx b/app/components/Sidebar/Shared.tsx index 90508cd9c..314e88b46 100644 --- a/app/components/Sidebar/Shared.tsx +++ b/app/components/Sidebar/Shared.tsx @@ -3,12 +3,12 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { NavigationNode } from "@shared/types"; -import Team from "~/models/Team"; import Scrollable from "~/components/Scrollable"; import SearchPopover from "~/components/SearchPopover"; import useStores from "~/hooks/useStores"; import history from "~/utils/history"; import { homePath, sharedDocumentPath } from "~/utils/routeHelpers"; +import { IAvatar } from "../Avatar/Avatar"; import TeamLogo from "../TeamLogo"; import Sidebar from "./Sidebar"; import HeaderButton from "./components/HeaderButton"; @@ -16,7 +16,7 @@ import Section from "./components/Section"; import DocumentLink from "./components/SharedDocumentLink"; type Props = { - team?: Team; + team?: IAvatar & { name: string }; rootNode: NavigationNode; shareId: string; }; diff --git a/app/components/Sidebar/components/EditableTitle.tsx b/app/components/Sidebar/components/EditableTitle.tsx index dd8bdac18..e9cce0b5b 100644 --- a/app/components/Sidebar/components/EditableTitle.tsx +++ b/app/components/Sidebar/components/EditableTitle.tsx @@ -123,7 +123,7 @@ const Input = styled.input` height: 32px; &:focus { - outline-color: ${(props) => props.theme.primary}; + outline-color: ${(props) => props.theme.accent}; } `; diff --git a/app/components/SkipNavLink.tsx b/app/components/SkipNavLink.tsx index bcd7df572..544583b58 100644 --- a/app/components/SkipNavLink.tsx +++ b/app/components/SkipNavLink.tsx @@ -25,7 +25,7 @@ const Anchor = styled.a` left: 12px; background: ${(props) => props.theme.background}; color: ${(props) => props.theme.text}; - outline-color: ${(props) => props.theme.primary}; + outline-color: ${(props) => props.theme.accent}; z-index: ${depths.popover}; width: auto; height: auto; diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx index fbe9257f9..df371fea6 100644 --- a/app/components/Switch.tsx +++ b/app/components/Switch.tsx @@ -124,11 +124,11 @@ const HiddenInput = styled.input<{ width: number; height: number }>` } &:checked + ${Slider} { - background-color: ${(props) => props.theme.primary}; + background-color: ${(props) => props.theme.accent}; } &:focus + ${Slider} { - box-shadow: 0 0 1px ${(props) => props.theme.primary}; + box-shadow: 0 0 1px ${(props) => props.theme.accent}; } &:checked + ${Slider}:before { diff --git a/app/components/Theme.tsx b/app/components/Theme.tsx index 417ab7d4d..bbe8bd5b4 100644 --- a/app/components/Theme.tsx +++ b/app/components/Theme.tsx @@ -1,26 +1,19 @@ import { observer } from "mobx-react"; import * as React from "react"; import { ThemeProvider } from "styled-components"; -import { breakpoints } from "@shared/styles"; import GlobalStyles from "@shared/styles/globals"; -import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme"; -import { UserPreference } from "@shared/types"; -import useMediaQuery from "~/hooks/useMediaQuery"; +import { TeamPreference, UserPreference } from "@shared/types"; +import useBuildTheme from "~/hooks/useBuildTheme"; import useStores from "~/hooks/useStores"; import { TooltipStyles } from "./Tooltip"; const Theme: React.FC = ({ children }) => { const { auth, ui } = useStores(); - const resolvedTheme = ui.resolvedTheme === "dark" ? dark : light; - const resolvedMobileTheme = - ui.resolvedTheme === "dark" ? darkMobile : lightMobile; - const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`); - const isPrinting = useMediaQuery("print"); - const theme = isPrinting - ? light - : isMobile - ? resolvedMobileTheme - : resolvedTheme; + const theme = useBuildTheme( + auth.team?.getPreference(TeamPreference.CustomTheme) || + auth.config?.customTheme || + undefined + ); React.useEffect(() => { window.dispatchEvent( diff --git a/app/hooks/useBuildTheme.ts b/app/hooks/useBuildTheme.ts new file mode 100644 index 000000000..c629309fa --- /dev/null +++ b/app/hooks/useBuildTheme.ts @@ -0,0 +1,37 @@ +import * as React from "react"; +import { breakpoints } from "@shared/styles"; +import { + buildDarkTheme, + buildLightTheme, + buildPitchBlackTheme, +} from "@shared/styles/theme"; +import { CustomTheme } from "@shared/types"; +import useMediaQuery from "~/hooks/useMediaQuery"; +import useStores from "./useStores"; + +/** + * Builds a theme based on the current user's preferences, the current device + * and the custom theme provided. + * + * @param customTheme Custom theme to merge with the default theme + * @returns The theme to use + */ +export default function useBuildTheme(customTheme: Partial = {}) { + const { ui } = useStores(); + const isMobile = useMediaQuery(`(max-width: ${breakpoints.tablet}px)`); + const isPrinting = useMediaQuery("print"); + + const theme = React.useMemo(() => { + return isPrinting + ? buildLightTheme(customTheme) + : isMobile + ? ui.resolvedTheme === "dark" + ? buildPitchBlackTheme(customTheme) + : buildLightTheme(customTheme) + : ui.resolvedTheme === "dark" + ? buildDarkTheme(customTheme) + : buildLightTheme(customTheme); + }, [customTheme, isMobile, isPrinting, ui.resolvedTheme]); + + return theme; +} diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index bf305c7fe..24e0928a0 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -40,7 +40,7 @@ import { accountPreferencesPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; -type SettingsGroups = "Account" | "Team" | "Integrations"; +type SettingsGroups = "Account" | "Workspace" | "Integrations"; export type ConfigItem = { name: string; @@ -100,7 +100,7 @@ const useSettingsConfig = () => { path: "/settings/details", component: Details, enabled: can.update, - group: t("Team"), + group: t("Workspace"), icon: TeamIcon, }, Security: { @@ -108,7 +108,7 @@ const useSettingsConfig = () => { path: "/settings/security", component: Security, enabled: can.update, - group: t("Team"), + group: t("Workspace"), icon: PadlockIcon, }, Features: { @@ -116,7 +116,7 @@ const useSettingsConfig = () => { path: "/settings/features", component: Features, enabled: can.update, - group: t("Team"), + group: t("Workspace"), icon: BeakerIcon, }, Members: { @@ -124,7 +124,7 @@ const useSettingsConfig = () => { path: "/settings/members", component: Members, enabled: true, - group: t("Team"), + group: t("Workspace"), icon: UserIcon, }, Groups: { @@ -132,7 +132,7 @@ const useSettingsConfig = () => { path: "/settings/groups", component: Groups, enabled: true, - group: t("Team"), + group: t("Workspace"), icon: GroupIcon, }, Shares: { @@ -140,7 +140,7 @@ const useSettingsConfig = () => { path: "/settings/shares", component: Shares, enabled: true, - group: t("Team"), + group: t("Workspace"), icon: LinkIcon, }, Import: { @@ -148,7 +148,7 @@ const useSettingsConfig = () => { path: "/settings/import", component: Import, enabled: can.createImport, - group: t("Team"), + group: t("Workspace"), icon: ImportIcon, }, Export: { @@ -156,7 +156,7 @@ const useSettingsConfig = () => { path: "/settings/export", component: Export, enabled: can.createExport, - group: t("Team"), + group: t("Workspace"), icon: ExportIcon, }, // Integrations diff --git a/app/index.tsx b/app/index.tsx index 1ea7bf2c4..e0f34dbf7 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -100,7 +100,7 @@ window.addEventListener("load", async () => { }); }); -if ("serviceWorker" in navigator) { +if ("serviceWorker" in navigator && env.ENVIRONMENT !== "development") { window.addEventListener("load", () => { // see: https://bugs.chromium.org/p/chromium/issues/detail?id=1097616 // In some rare (<0.1% of cases) this call can return `undefined` diff --git a/app/models/Team.ts b/app/models/Team.ts index 02248a6be..a3ab9e97c 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -90,7 +90,7 @@ class Team extends BaseModel { get seamlessEditing(): boolean { return ( this.collaborativeEditing && - this.getPreference(TeamPreference.SeamlessEdit, true) + !!this.getPreference(TeamPreference.SeamlessEdit, true) ); } @@ -102,7 +102,10 @@ class Team extends BaseModel { * @param fallback An optional fallback value, defaults to false. * @returns The value */ - getPreference(key: TeamPreference, fallback = false): boolean { + getPreference( + key: T, + fallback = false + ): TeamPreferences[T] | false { return this.preferences?.[key] ?? fallback; } diff --git a/app/scenes/Document/Shared.tsx b/app/scenes/Document/Shared.tsx index 3d1395793..c90c005bc 100644 --- a/app/scenes/Document/Shared.tsx +++ b/app/scenes/Document/Shared.tsx @@ -4,17 +4,17 @@ import * as React from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { RouteComponentProps, useLocation, Redirect } from "react-router-dom"; -import styled, { useTheme } from "styled-components"; +import styled, { ThemeProvider } from "styled-components"; import { setCookie } from "tiny-cookie"; -import { NavigationNode } from "@shared/types"; +import { CustomTheme, NavigationNode } from "@shared/types"; import DocumentModel from "~/models/Document"; -import Team from "~/models/Team"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; import Layout from "~/components/Layout"; import Sidebar from "~/components/Sidebar/Shared"; import Text from "~/components/Text"; import env from "~/env"; +import useBuildTheme from "~/hooks/useBuildTheme"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { AuthorizationError, OfflineError } from "~/utils/errors"; @@ -28,7 +28,11 @@ const EMPTY_OBJECT = {}; type Response = { document: DocumentModel; - team?: Team; + team?: { + name: string; + avatarUrl: string; + customTheme?: Partial; + }; sharedTree?: NavigationNode | undefined; }; @@ -81,7 +85,6 @@ function useDocumentId(documentSlug: string, response?: Response) { function SharedDocumentScene(props: Props) { const { ui, auth } = useStores(); - const theme = useTheme(); const location = useLocation(); const searchParams = React.useMemo( () => new URLSearchParams(location.search), @@ -94,6 +97,7 @@ function SharedDocumentScene(props: Props) { const { shareId, documentSlug } = props.match.params; const documentId = useDocumentId(documentSlug, response); const can = usePolicy(response?.document.id ?? ""); + const theme = useBuildTheme(response?.team?.customTheme); React.useEffect(() => { if (!auth.user) { @@ -177,15 +181,17 @@ function SharedDocumentScene(props: Props) { href={canonicalOrigin + location.pathname.replace(/\/$/, "")} /> - - - + + + + + ); } diff --git a/app/scenes/Document/components/Contents.tsx b/app/scenes/Document/components/Contents.tsx index a35d6d9ce..06002988f 100644 --- a/app/scenes/Document/components/Contents.tsx +++ b/app/scenes/Document/components/Contents.tsx @@ -140,8 +140,7 @@ const ListItem = styled.li<{ level: number; active?: boolean }>` a { font-weight: ${(props) => (props.active ? "600" : "inherit")}; - color: ${(props) => - props.active ? props.theme.primary : props.theme.text}; + color: ${(props) => (props.active ? props.theme.accent : props.theme.text)}; } `; @@ -150,7 +149,7 @@ const Link = styled.a` font-size: 14px; &:hover { - color: ${(props) => props.theme.primary}; + color: ${(props) => props.theme.accent}; } `; diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 3f2e769ea..e215d78f9 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -1,13 +1,19 @@ +import { isHexColor } from "class-validator"; +import { pickBy } from "lodash"; import { observer } from "mobx-react"; import { TeamIcon } from "outline-icons"; import { useRef, useState } from "react"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; +import { ThemeProvider, useTheme } from "styled-components"; +import { buildDarkTheme, buildLightTheme } from "@shared/styles/theme"; +import { CustomTheme } from "@shared/types"; import { getBaseDomain } from "@shared/utils/domains"; import Button from "~/components/Button"; import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect"; import Heading from "~/components/Heading"; import Input from "~/components/Input"; +import InputColor from "~/components/InputColor"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; import env from "~/env"; @@ -19,17 +25,30 @@ import ImageInput from "./components/ImageInput"; import SettingRow from "./components/SettingRow"; function Details() { - const { auth } = useStores(); + const { auth, ui } = useStores(); const { showToast } = useToasts(); const { t } = useTranslation(); const team = useCurrentTeam(); + const theme = useTheme(); const form = useRef(null); + const [accent, setAccent] = useState(team.preferences?.customTheme?.accent); + const [accentText, setAccentText] = useState( + team.preferences?.customTheme?.accentText + ); const [name, setName] = useState(team.name); const [subdomain, setSubdomain] = useState(team.subdomain); const [defaultCollectionId, setDefaultCollectionId] = useState( team.defaultCollectionId ); + const customTheme: Partial = pickBy( + { + accent, + accentText, + }, + isHexColor + ); + const handleSubmit = React.useCallback( async (event?: React.SyntheticEvent) => { if (event) { @@ -41,6 +60,10 @@ function Details() { name, subdomain, defaultCollectionId, + preferences: { + ...team.preferences, + customTheme, + }, }); showToast(t("Settings saved"), { type: "success", @@ -51,7 +74,16 @@ function Details() { }); } }, - [auth, name, subdomain, defaultCollectionId, showToast, t] + [ + auth, + name, + subdomain, + defaultCollectionId, + team.preferences, + customTheme, + showToast, + t, + ] ); const handleNameChange = React.useCallback( @@ -91,92 +123,129 @@ function Details() { const isValid = form.current?.checkValidity(); + const newTheme = React.useMemo( + () => + ui.resolvedTheme === "light" + ? buildLightTheme(customTheme) + : buildDarkTheme(customTheme), + [customTheme, ui.resolvedTheme] + ); + return ( - }> - {t("Details")} - - - These settings affect the way that your knowledge base appears to - everyone on the team. - - + + }> + {t("Details")} + + + These settings affect the way that your knowledge base appears to + everyone on the team. + + -
- - - - - - - - Your knowledge base will be accessible at{" "} - - {subdomain}.{getBaseDomain()} - - - ) : ( - t("Choose a subdomain to enable a login page just for your team.") - ) - } - > - - - - - + + {t("Display")} + + + + + + + + + + - -
-
+ {t("Behavior")} + + + Your knowledge base will be accessible at{" "} + + {subdomain}.{getBaseDomain()} + + + ) : ( + t( + "Choose a subdomain to enable a login page just for your team." + ) + ) + } + > + + + + + + + + +
+ ); } diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 57a870ff2..4a274e86e 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -166,7 +166,7 @@ function Security() { > {" "} diff --git a/app/scenes/Settings/components/FileOperationListItem.tsx b/app/scenes/Settings/components/FileOperationListItem.tsx index 26dfe3437..070506804 100644 --- a/app/scenes/Settings/components/FileOperationListItem.tsx +++ b/app/scenes/Settings/components/FileOperationListItem.tsx @@ -37,7 +37,7 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => { [FileOperationState.Creating]: , [FileOperationState.Uploading]: , [FileOperationState.Expired]: , - [FileOperationState.Complete]: , + [FileOperationState.Complete]: , [FileOperationState.Error]: , }; diff --git a/app/scenes/Settings/components/SharesTable.tsx b/app/scenes/Settings/components/SharesTable.tsx index 8532db716..52032c0e0 100644 --- a/app/scenes/Settings/components/SharesTable.tsx +++ b/app/scenes/Settings/components/SharesTable.tsx @@ -63,7 +63,7 @@ function SharesTable({ canManage, ...rest }: Props) { Cell: observer(({ value }: { value: string }) => value ? ( - + ) : null ), @@ -89,7 +89,7 @@ function SharesTable({ canManage, ...rest }: Props) { } : undefined, ].filter((i) => i), - [t, theme.primary, canManage] + [t, theme.accent, canManage] ); return ; diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 5c55faeb4..42bb6d8b5 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -2,7 +2,7 @@ import * as Sentry from "@sentry/react"; import invariant from "invariant"; import { observable, action, computed, autorun, runInAction } from "mobx"; import { getCookie, setCookie, removeCookie } from "tiny-cookie"; -import { TeamPreferences, UserPreferences } from "@shared/types"; +import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types"; import Storage from "@shared/utils/Storage"; import { getCookieDomain, parseDomain } from "@shared/utils/domains"; import RootStore from "~/stores/RootStore"; @@ -38,6 +38,7 @@ type Provider = { export type Config = { name?: string; logo?: string; + customTheme?: Partial; hostname?: string; providers: Provider[]; }; diff --git a/app/typings/styled-components.d.ts b/app/typings/styled-components.d.ts index 5a4d4a079..35815005f 100644 --- a/app/typings/styled-components.d.ts +++ b/app/typings/styled-components.d.ts @@ -68,6 +68,7 @@ declare module "styled-components" { smokeLight: string; smokeDark: string; white: string; + white05: string; white10: string; white50: string; white75: string; @@ -75,7 +76,8 @@ declare module "styled-components" { black05: string; black10: string; black50: string; - primary: string; + black75: string; + accent: string; yellow: string; warmGrey: string; searchHighlight: string; @@ -94,6 +96,16 @@ declare module "styled-components" { }; } + interface Breakpoints { + breakpoints: { + mobile: number; + mobileLarge: number; + tablet: number; + desktop: number; + desktopLarge: number; + }; + } + interface Spacing { padding: string; vpadding: string; @@ -104,11 +116,15 @@ declare module "styled-components" { sidebarMaxWidth: number; } - export interface DefaultTheme extends Colors, Spacing, EditorTheme { + export interface DefaultTheme + extends Colors, + Spacing, + Breakpoints, + EditorTheme { background: string; backgroundTransition: string; - buttonBackground: string; - buttonText: string; + accent: string; + accentText: string; secondaryBackground: string; link: string; text: string; diff --git a/server/commands/teamUpdater.ts b/server/commands/teamUpdater.ts index 28f339906..9db0ee4d1 100644 --- a/server/commands/teamUpdater.ts +++ b/server/commands/teamUpdater.ts @@ -107,7 +107,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => { if (preferences) { for (const value of Object.values(TeamPreference)) { if (has(preferences, value)) { - team.setPreference(value, Boolean(preferences[value])); + team.setPreference(value, preferences[value]); } } } diff --git a/server/models/Team.ts b/server/models/Team.ts index bda9db6d0..6379e739f 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -20,7 +20,11 @@ import { AllowNull, AfterUpdate, } from "sequelize-typescript"; -import { CollectionPermission, TeamPreference } from "@shared/types"; +import { + CollectionPermission, + TeamPreference, + TeamPreferences, +} from "@shared/types"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import env from "@server/env"; import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask"; @@ -39,8 +43,6 @@ import NotContainsUrl from "./validators/NotContainsUrl"; const readFile = util.promisify(fs.readFile); -export type TeamPreferences = Record; - @Scopes(() => ({ withDomains: { include: [{ model: TeamDomain }], @@ -184,7 +186,10 @@ class Team extends ParanoidModel { * @param value Sets the preference value * @returns The current team preferences */ - public setPreference = (preference: TeamPreference, value: boolean) => { + public setPreference = ( + preference: T, + value: TeamPreferences[T] + ) => { if (!this.preferences) { this.preferences = {}; } diff --git a/server/presenters/index.ts b/server/presenters/index.ts index 697cfec7f..f23ba3b3b 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -15,6 +15,7 @@ import presentNotificationSetting from "./notificationSetting"; import presentPin from "./pin"; import presentPolicies from "./policy"; import presentProviderConfig from "./providerConfig"; +import presentPublicTeam from "./publicTeam"; import presentRevision from "./revision"; import presentSearchQuery from "./searchQuery"; import presentShare from "./share"; @@ -39,6 +40,7 @@ export { presentIntegration, presentMembership, presentNotificationSetting, + presentPublicTeam, presentPin, presentPolicies, presentProviderConfig, diff --git a/server/presenters/publicTeam.ts b/server/presenters/publicTeam.ts new file mode 100644 index 000000000..9eeed2331 --- /dev/null +++ b/server/presenters/publicTeam.ts @@ -0,0 +1,9 @@ +import { Team } from "@server/models"; + +export default function presentPublicTeam(team: Team) { + return { + name: team.name, + avatarUrl: team.avatarUrl, + customTheme: team.preferences?.customTheme, + }; +} diff --git a/server/routes/api/auth.ts b/server/routes/api/auth.ts index 48c6a543a..2696b39ef 100644 --- a/server/routes/api/auth.ts +++ b/server/routes/api/auth.ts @@ -31,6 +31,7 @@ router.post("auth.config", async (ctx: APIContext) => { ctx.body = { data: { name: team.name, + customTheme: team.getPreference(TeamPreference.CustomTheme), logo: team.getPreference(TeamPreference.PublicBranding) ? team.avatarUrl : undefined, @@ -56,6 +57,7 @@ router.post("auth.config", async (ctx: APIContext) => { ctx.body = { data: { name: team.name, + customTheme: team.getPreference(TeamPreference.CustomTheme), logo: team.getPreference(TeamPreference.PublicBranding) ? team.avatarUrl : undefined, @@ -82,6 +84,7 @@ router.post("auth.config", async (ctx: APIContext) => { ctx.body = { data: { name: team.name, + customTheme: team.getPreference(TeamPreference.CustomTheme), logo: team.getPreference(TeamPreference.PublicBranding) ? team.avatarUrl : undefined, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index fca3fbebf..3b4eaf048 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1,7 +1,6 @@ import fs from "fs-extra"; import invariant from "invariant"; import Router from "koa-router"; -import { pick } from "lodash"; import mime from "mime-types"; import { Op, ScopeOptions, WhereOptions } from "sequelize"; import { TeamPreference } from "@shared/types"; @@ -41,6 +40,7 @@ import { presentCollection, presentDocument, presentPolicies, + presentPublicTeam, } from "@server/presenters"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; @@ -419,7 +419,7 @@ router.post( ? { document: serializedDocument, team: team?.getPreference(TeamPreference.PublicBranding) - ? pick(team, ["avatarUrl", "name"]) + ? presentPublicTeam(team) : undefined, sharedTree: share && share.includeChildDocuments diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index e908ca51e..d812f2048 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -29,7 +29,7 @@ import searches from "./searches"; import shares from "./shares"; import stars from "./stars"; import subscriptions from "./subscriptions"; -import team from "./team"; +import teams from "./teams"; import users from "./users"; import views from "./views"; @@ -74,7 +74,7 @@ router.use("/", searches.routes()); router.use("/", shares.routes()); router.use("/", stars.routes()); router.use("/", subscriptions.routes()); -router.use("/", team.routes()); +router.use("/", teams.routes()); router.use("/", integrations.routes()); router.use("/", notificationSettings.routes()); router.use("/", attachments.routes()); diff --git a/server/routes/api/teams/index.ts b/server/routes/api/teams/index.ts new file mode 100644 index 000000000..cbf87af18 --- /dev/null +++ b/server/routes/api/teams/index.ts @@ -0,0 +1 @@ +export { default } from "./teams"; diff --git a/server/routes/api/teams/schema.ts b/server/routes/api/teams/schema.ts new file mode 100644 index 000000000..9deb0237d --- /dev/null +++ b/server/routes/api/teams/schema.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { UserRole } from "@server/models/User"; +import BaseSchema from "@server/routes/api/BaseSchema"; + +export const TeamsUpdateSchema = BaseSchema.extend({ + body: z.object({ + /** Team name */ + name: z.string().optional(), + /** Avatar URL */ + avatarUrl: z.string().optional(), + /** The subdomain to access the team */ + subdomain: z.string().optional(), + /** Whether public sharing is enabled */ + sharing: z.boolean().optional(), + /** Whether siginin with email is enabled */ + guestSignin: z.boolean().optional(), + /** Whether third-party document embeds are enabled */ + documentEmbeds: z.boolean().optional(), + /** Whether team members are able to create new collections */ + memberCollectionCreate: z.boolean().optional(), + /** Whether collaborative editing is enabled */ + collaborativeEditing: z.boolean().optional(), + /** The default landing collection for the team */ + defaultCollectionId: z.string().uuid().nullish(), + /** The default user role */ + defaultUserRole: z + .string() + .refine((val) => Object.values(UserRole).includes(val as UserRole)) + .optional(), + /** Whether new users must be invited to join the team */ + inviteRequired: z.boolean().optional(), + /** Domains allowed to sign-in with SSO */ + allowedDomains: z.array(z.string()).optional(), + /** Team preferences */ + preferences: z + .object({ + /** Whether documents have a separate edit mode instead of seamless editing. */ + seamlessEdit: z.boolean().optional(), + /** Whether to use team logo across the app for branding. */ + publicBranding: z.boolean().optional(), + /** Whether viewers should see download options. */ + viewersCanExport: z.boolean().optional(), + /** The custom theme for the team. */ + customTheme: z + .object({ + accent: z.string().min(4).max(7).regex(/^#/).optional(), + accentText: z.string().min(4).max(7).regex(/^#/).optional(), + }) + .optional(), + }) + .optional(), + }), +}); + +export type TeamsUpdateSchemaReq = z.infer; diff --git a/server/routes/api/team.test.ts b/server/routes/api/teams/teams.test.ts similarity index 100% rename from server/routes/api/team.test.ts rename to server/routes/api/teams/teams.test.ts diff --git a/server/routes/api/team.ts b/server/routes/api/teams/teams.ts similarity index 76% rename from server/routes/api/team.ts rename to server/routes/api/teams/teams.ts index f6e3a38ec..2952ef322 100644 --- a/server/routes/api/team.ts +++ b/server/routes/api/teams/teams.ts @@ -5,12 +5,13 @@ import teamUpdater from "@server/commands/teamUpdater"; import { sequelize } from "@server/database/sequelize"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; +import validate from "@server/middlewares/validate"; import { Event, Team, TeamDomain, User } from "@server/models"; import { authorize } from "@server/policies"; import { presentTeam, presentPolicies } from "@server/presenters"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; -import { assertUuid } from "@server/validation"; +import * as T from "./schema"; const router = new Router(); @@ -18,49 +19,16 @@ router.post( "team.update", auth(), rateLimiter(RateLimiterStrategy.TenPerHour), - async (ctx: APIContext) => { - const { - name, - avatarUrl, - subdomain, - sharing, - guestSignin, - documentEmbeds, - memberCollectionCreate, - collaborativeEditing, - defaultCollectionId, - defaultUserRole, - inviteRequired, - allowedDomains, - preferences, - } = ctx.request.body; - + validate(T.TeamsUpdateSchema), + async (ctx: APIContext) => { const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId, { include: [{ model: TeamDomain }], }); authorize(user, "update", team); - if (defaultCollectionId !== undefined && defaultCollectionId !== null) { - assertUuid(defaultCollectionId, "defaultCollectionId must be uuid"); - } - const updatedTeam = await teamUpdater({ - params: { - name, - avatarUrl, - subdomain, - sharing, - guestSignin, - documentEmbeds, - memberCollectionCreate, - collaborativeEditing, - defaultCollectionId, - defaultUserRole, - inviteRequired, - allowedDomains, - preferences, - }, + params: ctx.input.body, user, team, ip: ctx.request.ip, diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 62dd5507a..fa14fe142 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -817,7 +817,7 @@ ul.checkbox_list li .checkbox { &[aria-checked=true] { opacity: 1; background-image: ${`url( - "data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0C1.34315 0 0 1.34315 0 3V11C0 12.6569 1.34315 14 3 14H11C12.6569 14 14 12.6569 14 11V3C14 1.34315 12.6569 0 11 0H3ZM4.26825 5.85982L5.95873 7.88839L9.70003 2.9C10.0314 2.45817 10.6582 2.36863 11.1 2.7C11.5419 3.03137 11.6314 3.65817 11.3 4.1L6.80002 10.1C6.41275 10.6164 5.64501 10.636 5.2318 10.1402L2.7318 7.14018C2.37824 6.71591 2.43556 6.08534 2.85984 5.73178C3.28412 5.37821 3.91468 5.43554 4.26825 5.85982Z' fill='${props.theme.primary.replace( + "data:image/svg+xml,%3Csvg width='14' height='14' viewBox='0 0 14 14' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3 0C1.34315 0 0 1.34315 0 3V11C0 12.6569 1.34315 14 3 14H11C12.6569 14 14 12.6569 14 11V3C14 1.34315 12.6569 0 11 0H3ZM4.26825 5.85982L5.95873 7.88839L9.70003 2.9C10.0314 2.45817 10.6582 2.36863 11.1 2.7C11.5419 3.03137 11.6314 3.65817 11.3 4.1L6.80002 10.1C6.41275 10.6164 5.64501 10.636 5.2318 10.1402L2.7318 7.14018C2.37824 6.71591 2.43556 6.08534 2.85984 5.73178C3.28412 5.37821 3.91468 5.43554 4.26825 5.85982Z' fill='${props.theme.accent.replace( "#", "%23" )}' /%3E%3C/svg%3E%0A" diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index e0c70f4f6..54161f9eb 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -185,6 +185,7 @@ "Show menu": "Show menu", "Choose icon": "Choose icon", "Loading": "Loading", + "Select a color": "Select a color", "Loading editor": "Loading editor", "Search": "Search", "Default access": "Default access", @@ -305,7 +306,6 @@ "Notifications": "Notifications", "API Tokens": "API Tokens", "Details": "Details", - "Team": "Team", "Security": "Security", "Features": "Features", "Members": "Members", @@ -674,8 +674,14 @@ "Logo updated": "Logo updated", "Unable to upload new logo": "Unable to upload new logo", "These settings affect the way that your knowledge base appears to everyone on the team.": "These settings affect the way that your knowledge base appears to everyone on the team.", + "Display": "Display", "The logo is displayed at the top left of the application.": "The logo is displayed at the top left of the application.", "The workspace name, usually the same as your company name.": "The workspace name, usually the same as your company name.", + "Theme": "Theme", + "Customize the interface look and feel.": "Customize the interface look and feel.", + "Accent color": "Accent color", + "Accent text color": "Accent text color", + "Behavior": "Behavior", "Subdomain": "Subdomain", "Your knowledge base will be accessible at": "Your knowledge base will be accessible at", "Choose a subdomain to enable a login page just for your team.": "Choose a subdomain to enable a login page just for your team.", @@ -729,14 +735,12 @@ "Preferences saved": "Preferences saved", "Delete account": "Delete account", "Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.", - "Display": "Display", "Language": "Language", "Choose the interface language. Community translations are accepted though our <2>translation portal.": "Choose the interface language. Community translations are accepted though our <2>translation portal.", "Use pointer cursor": "Use pointer cursor", "Show a hand cursor when hovering over interactive elements.": "Show a hand cursor when hovering over interactive elements.", "Show line numbers": "Show line numbers", "Show line numbers on code blocks in documents.": "Show line numbers on code blocks in documents.", - "Behavior": "Behavior", "Remember previous location": "Remember previous location", "Automatically return to the document you were last viewing when the app is re-opened.": "Automatically return to the document you were last viewing when the app is re-opened.", "You may delete your account at any time, note that this is unrecoverable": "You may delete your account at any time, note that this is unrecoverable", diff --git a/shared/styles/globals.ts b/shared/styles/globals.ts index b2a2f8d1d..9c1436ab8 100644 --- a/shared/styles/globals.ts +++ b/shared/styles/globals.ts @@ -2,7 +2,9 @@ import { createGlobalStyle } from "styled-components"; import styledNormalize from "styled-normalize"; import { breakpoints, depths } from "."; -type Props = { useCursorPointer?: boolean }; +type Props = { + useCursorPointer?: boolean; +}; export default createGlobalStyle` ${styledNormalize} @@ -108,7 +110,7 @@ export default createGlobalStyle` } .js-focus-visible .focus-visible { - outline-color: ${(props) => props.theme.primary}; + outline-color: ${(props) => props.theme.accent}; outline-offset: -1px; } `; diff --git a/shared/styles/theme.ts b/shared/styles/theme.ts index f28d552c0..3c6812355 100644 --- a/shared/styles/theme.ts +++ b/shared/styles/theme.ts @@ -1,7 +1,8 @@ import { darken, lighten } from "polished"; +import { DefaultTheme, Colors } from "styled-components"; import breakpoints from "./breakpoints"; -const colors = { +const defaultColors: Colors = { transparent: "transparent", almostBlack: "#111319", lightBlack: "#2F3336", @@ -13,7 +14,7 @@ const colors = { smoke: "#F4F7FA", smokeLight: "#F9FBFC", smokeDark: "#E8EBED", - white: "#FFF", + white: "#FFFFFF", white05: "rgba(255, 255, 255, 0.05)", white10: "rgba(255, 255, 255, 0.1)", white50: "rgba(255, 255, 255, 0.5)", @@ -23,7 +24,7 @@ const colors = { black10: "rgba(0, 0, 0, 0.1)", black50: "rgba(0, 0, 0, 0.50)", black75: "rgba(0, 0, 0, 0.75)", - primary: "#0366d6", + accent: "#0366d6", yellow: "#EDBA07", warmGrey: "#EDF2F7", searchHighlight: "#FDEA9B", @@ -52,178 +53,196 @@ const spacing = { sidebarMaxWidth: 400, }; -export const base = { - ...colors, - ...spacing, - fontFamily: - "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif", - fontFamilyMono: - "'SFMono-Regular',Consolas,'Liberation Mono', Menlo, Courier,monospace", - fontWeight: 400, - backgroundTransition: "background 100ms ease-in-out", - selected: colors.primary, - buttonBackground: colors.primary, - buttonText: colors.white, - textHighlight: "#FDEA9B", - textHighlightForeground: colors.almostBlack, - code: colors.lightBlack, - codeComment: "#6a737d", - codePunctuation: "#5e6687", - codeNumber: "#d73a49", - codeProperty: "#c08b30", - codeTag: "#3d8fd1", - codeString: "#032f62", - codeSelector: "#6679cc", - codeAttr: "#c76b29", - codeEntity: "#22a2c9", - codeKeyword: "#d73a49", - codeFunction: "#6f42c1", - codeStatement: "#22a2c9", - codePlaceholder: "#3d8fd1", - codeInserted: "#202746", - codeImportant: "#c94922", - noticeInfoBackground: colors.primary, - noticeInfoText: colors.almostBlack, - noticeTipBackground: "#F5BE31", - noticeTipText: colors.almostBlack, - noticeWarningBackground: "#d73a49", - noticeWarningText: colors.almostBlack, - breakpoints, +const buildBaseTheme = (input: Partial) => { + const colors = { + ...defaultColors, + ...input, + }; + + return { + fontFamily: + "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Oxygen, Ubuntu,Cantarell,'Open Sans','Helvetica Neue',sans-serif", + fontFamilyMono: + "'SFMono-Regular',Consolas,'Liberation Mono', Menlo, Courier,monospace", + fontWeight: 400, + backgroundTransition: "background 100ms ease-in-out", + accentText: colors.white, + selected: colors.accent, + textHighlight: "#FDEA9B", + textHighlightForeground: colors.almostBlack, + code: colors.lightBlack, + codeComment: "#6a737d", + codePunctuation: "#5e6687", + codeNumber: "#d73a49", + codeProperty: "#c08b30", + codeTag: "#3d8fd1", + codeString: "#032f62", + codeSelector: "#6679cc", + codeAttr: "#c76b29", + codeEntity: "#22a2c9", + codeKeyword: "#d73a49", + codeFunction: "#6f42c1", + codeStatement: "#22a2c9", + codePlaceholder: "#3d8fd1", + codeInserted: "#202746", + codeImportant: "#c94922", + noticeInfoBackground: colors.accent, + noticeInfoText: colors.almostBlack, + noticeTipBackground: "#F5BE31", + noticeTipText: colors.almostBlack, + noticeWarningBackground: "#d73a49", + noticeWarningText: colors.almostBlack, + breakpoints, + ...colors, + ...spacing, + }; }; -export const light = { - ...base, - isDark: false, - background: colors.white, - secondaryBackground: colors.warmGrey, - link: colors.primary, - cursor: colors.almostBlack, - text: colors.almostBlack, - textSecondary: colors.slateDark, - textTertiary: colors.slate, - textDiffInserted: colors.almostBlack, - textDiffInsertedBackground: "rgba(18, 138, 41, 0.16)", - textDiffDeleted: colors.slateDark, - textDiffDeletedBackground: "#ffebe9", - placeholder: "#a2b2c3", - sidebarBackground: colors.warmGrey, - sidebarActiveBackground: "#d7e0ea", - sidebarControlHoverBackground: "rgb(138 164 193 / 20%)", - sidebarDraftBorder: darken("0.25", colors.warmGrey), - sidebarText: "rgb(78, 92, 110)", - backdrop: "rgba(0, 0, 0, 0.2)", - shadow: "rgba(0, 0, 0, 0.2)", +export const buildLightTheme = (input: Partial): DefaultTheme => { + const colors = buildBaseTheme(input); - modalBackdrop: colors.black10, - modalBackground: colors.white, - modalShadow: - "0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)", + return { + ...colors, + isDark: false, + background: colors.white, + secondaryBackground: colors.warmGrey, + link: colors.accent, + cursor: colors.almostBlack, + text: colors.almostBlack, + textSecondary: colors.slateDark, + textTertiary: colors.slate, + textDiffInserted: colors.almostBlack, + textDiffInsertedBackground: "rgba(18, 138, 41, 0.16)", + textDiffDeleted: colors.slateDark, + textDiffDeletedBackground: "#ffebe9", + placeholder: "#a2b2c3", + sidebarBackground: colors.warmGrey, + sidebarActiveBackground: "#d7e0ea", + sidebarControlHoverBackground: "rgb(138 164 193 / 20%)", + sidebarDraftBorder: darken("0.25", colors.warmGrey), + sidebarText: "rgb(78, 92, 110)", + backdrop: "rgba(0, 0, 0, 0.2)", + shadow: "rgba(0, 0, 0, 0.2)", - menuItemSelected: colors.warmGrey, - menuBackground: colors.white, - menuShadow: - "0 0 0 1px rgb(0 0 0 / 2%), 0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)", - divider: colors.slateLight, - titleBarDivider: colors.slateLight, - inputBorder: colors.slateLight, - inputBorderFocused: colors.slate, - listItemHoverBackground: colors.warmGrey, - toolbarHoverBackground: colors.black, - toolbarBackground: colors.almostBlack, - toolbarInput: colors.white10, - toolbarItem: colors.white, - tableDivider: colors.smokeDark, - tableSelected: colors.primary, - tableSelectedBackground: "#E5F7FF", - buttonNeutralBackground: colors.white, - buttonNeutralText: colors.almostBlack, - buttonNeutralBorder: darken(0.15, colors.white), - tooltipBackground: colors.almostBlack, - tooltipText: colors.white, - toastBackground: colors.almostBlack, - toastText: colors.white, - quote: colors.slateLight, - codeBackground: colors.smoke, - codeBorder: colors.smokeDark, - embedBorder: colors.slateLight, - horizontalRule: colors.smokeDark, - progressBarBackground: colors.slateLight, - scrollbarBackground: colors.smoke, - scrollbarThumb: darken(0.15, colors.smokeDark), + modalBackdrop: colors.black10, + modalBackground: colors.white, + modalShadow: + "0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)", + + menuItemSelected: colors.warmGrey, + menuBackground: colors.white, + menuShadow: + "0 0 0 1px rgb(0 0 0 / 2%), 0 4px 8px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 0%), 0 30px 40px rgb(0 0 0 / 8%)", + divider: colors.slateLight, + titleBarDivider: colors.slateLight, + inputBorder: colors.slateLight, + inputBorderFocused: colors.slate, + listItemHoverBackground: colors.warmGrey, + toolbarHoverBackground: colors.black, + toolbarBackground: colors.almostBlack, + toolbarInput: colors.white10, + toolbarItem: colors.white, + tableDivider: colors.smokeDark, + tableSelected: colors.accent, + tableSelectedBackground: "#E5F7FF", + buttonNeutralBackground: colors.white, + buttonNeutralText: colors.almostBlack, + buttonNeutralBorder: darken(0.15, colors.white), + tooltipBackground: colors.almostBlack, + tooltipText: colors.white, + toastBackground: colors.almostBlack, + toastText: colors.white, + quote: colors.slateLight, + codeBackground: colors.smoke, + codeBorder: colors.smokeDark, + embedBorder: colors.slateLight, + horizontalRule: colors.smokeDark, + progressBarBackground: colors.slateLight, + scrollbarBackground: colors.smoke, + scrollbarThumb: darken(0.15, colors.smokeDark), + }; }; -export const dark = { - ...base, - isDark: true, - background: colors.almostBlack, - secondaryBackground: colors.black50, - link: "#137FFB", - text: colors.almostWhite, - cursor: colors.almostWhite, - textSecondary: lighten(0.1, colors.slate), - textTertiary: colors.slate, - textDiffInserted: colors.almostWhite, - textDiffInsertedBackground: "rgba(63,185,80,0.3)", - textDiffDeleted: darken(0.1, colors.almostWhite), - textDiffDeletedBackground: "rgba(248,81,73,0.15)", - placeholder: colors.slateDark, - sidebarBackground: colors.veryDarkBlue, - sidebarActiveBackground: lighten(0.02, colors.almostBlack), - sidebarControlHoverBackground: colors.white10, - sidebarDraftBorder: darken("0.35", colors.slate), - sidebarText: colors.slate, - backdrop: "rgba(0, 0, 0, 0.5)", - shadow: "rgba(0, 0, 0, 0.6)", +export const buildDarkTheme = (input: Partial): DefaultTheme => { + const colors = buildBaseTheme(input); - modalBackdrop: colors.black50, - modalBackground: "#1f2128", - modalShadow: - "0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)", + return { + ...colors, + isDark: true, + background: colors.almostBlack, + secondaryBackground: colors.black50, + link: "#137FFB", + text: colors.almostWhite, + cursor: colors.almostWhite, + textSecondary: lighten(0.1, colors.slate), + textTertiary: colors.slate, + textDiffInserted: colors.almostWhite, + textDiffInsertedBackground: "rgba(63,185,80,0.3)", + textDiffDeleted: darken(0.1, colors.almostWhite), + textDiffDeletedBackground: "rgba(248,81,73,0.15)", + placeholder: colors.slateDark, + sidebarBackground: colors.veryDarkBlue, + sidebarActiveBackground: lighten(0.02, colors.almostBlack), + sidebarControlHoverBackground: colors.white10, + sidebarDraftBorder: darken("0.35", colors.slate), + sidebarText: colors.slate, + backdrop: "rgba(0, 0, 0, 0.5)", + shadow: "rgba(0, 0, 0, 0.6)", - menuItemSelected: lighten(0.1, "#1f2128"), - menuBackground: "#1f2128", - menuShadow: - "0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)", - divider: lighten(0.1, colors.almostBlack), - titleBarDivider: darken(0.4, colors.slate), - inputBorder: colors.slateDark, - inputBorderFocused: colors.slate, - listItemHoverBackground: colors.white10, - toolbarHoverBackground: colors.slate, - toolbarBackground: colors.white, - toolbarInput: colors.black10, - toolbarItem: colors.lightBlack, - tableDivider: colors.lightBlack, - tableSelected: colors.primary, - tableSelectedBackground: "#002333", - buttonNeutralBackground: colors.almostBlack, - buttonNeutralText: colors.white, - buttonNeutralBorder: colors.slateDark, - tooltipBackground: colors.white, - tooltipText: colors.lightBlack, - toastBackground: colors.white, - toastText: colors.lightBlack, - quote: colors.almostWhite, - code: colors.almostWhite, - codeBackground: colors.black75, - codeBorder: colors.black50, - codeString: "#3d8fd1", - embedBorder: colors.black50, - horizontalRule: lighten(0.1, colors.almostBlack), - noticeInfoText: colors.white, - noticeTipText: colors.white, - noticeWarningText: colors.white, - progressBarBackground: colors.slate, - scrollbarBackground: colors.black, - scrollbarThumb: colors.lightBlack, + modalBackdrop: colors.black50, + modalBackground: "#1f2128", + modalShadow: + "0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)", + + menuItemSelected: lighten(0.1, "#1f2128"), + menuBackground: "#1f2128", + menuShadow: + "0 0 0 1px rgba(0, 0, 0, 0.1), 0 8px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.08)", + divider: lighten(0.1, colors.almostBlack), + titleBarDivider: darken(0.4, colors.slate), + inputBorder: colors.slateDark, + inputBorderFocused: colors.slate, + listItemHoverBackground: colors.white10, + toolbarHoverBackground: colors.slate, + toolbarBackground: colors.white, + toolbarInput: colors.black10, + toolbarItem: colors.lightBlack, + tableDivider: colors.lightBlack, + tableSelected: colors.accent, + tableSelectedBackground: "#002333", + buttonNeutralBackground: colors.almostBlack, + buttonNeutralText: colors.white, + buttonNeutralBorder: colors.slateDark, + tooltipBackground: colors.white, + tooltipText: colors.lightBlack, + toastBackground: colors.white, + toastText: colors.lightBlack, + quote: colors.almostWhite, + code: colors.almostWhite, + codeBackground: colors.black75, + codeBorder: colors.black50, + codeString: "#3d8fd1", + embedBorder: colors.black50, + horizontalRule: lighten(0.1, colors.almostBlack), + noticeInfoText: colors.white, + noticeTipText: colors.white, + noticeWarningText: colors.white, + progressBarBackground: colors.slate, + scrollbarBackground: colors.black, + scrollbarThumb: colors.lightBlack, + }; }; -export const lightMobile = light; +export const buildPitchBlackTheme = (input: Partial) => { + const colors = buildDarkTheme(input); -export const darkMobile = { - ...dark, - background: colors.black, - codeBackground: colors.almostBlack, + return { + ...colors, + background: colors.black, + codeBackground: colors.almostBlack, + }; }; -export default light; +export const light = buildLightTheme(defaultColors); + +export default light as DefaultTheme; diff --git a/shared/types.ts b/shared/types.ts index 699e7c67b..5ced48e5c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -108,16 +108,28 @@ export enum UserPreference { export type UserPreferences = { [key in UserPreference]?: boolean }; +export type CustomTheme = { + accent: string; + accentText: string; +}; + export enum TeamPreference { /** Whether documents have a separate edit mode instead of seamless editing. */ SeamlessEdit = "seamlessEdit", /** Whether to use team logo across the app for branding. */ PublicBranding = "publicBranding", - /** Whether viewers should see download options */ + /** Whether viewers should see download options. */ ViewersCanExport = "viewersCanExport", + /** The custom theme for the team. */ + CustomTheme = "customTheme", } -export type TeamPreferences = { [key in TeamPreference]?: boolean }; +export type TeamPreferences = { + [TeamPreference.SeamlessEdit]?: boolean; + [TeamPreference.PublicBranding]?: boolean; + [TeamPreference.ViewersCanExport]?: boolean; + [TeamPreference.CustomTheme]?: Partial; +}; export enum NavigationNodeType { Collection = "collection",