feat: Custom accent color (#4897)

* types

* Working, but messy

* Add InputColor component

* types

* Show default theme values when not customized

* Support custom theme on team sign-in page

* Payload validation

* Custom theme on shared documents

* Improve theme validation

* Team -> Workspace in settings
This commit is contained in:
Tom Moor
2023-02-19 10:43:03 -05:00
committed by GitHub
parent 7c05b7326a
commit 70beb7524f
45 changed files with 684 additions and 390 deletions

View File

@ -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;

View File

@ -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

View File

@ -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)<RealProps>`
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)<RealProps>`
&: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};

View File

@ -63,7 +63,7 @@ const CircularProgressBar = ({
<Circle color={theme.progressBarBackground} offset={offset} />
{percentage > 0 && (
<Circle
color={theme.primary}
color={theme.accent}
percentage={percentage}
offset={offset}
/>

View File

@ -147,13 +147,13 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
&: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<MenuAnchorProps>`
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};
}
`}

View File

@ -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 {

View File

@ -44,7 +44,7 @@ function DocumentTasks({ document }: Props) {
<>
{completed === total ? (
<Done
color={theme.primary}
color={theme.accent}
size={20}
$animated={done && previousDone === false}
/>

View File

@ -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) {
})}
</Icons>
<Colors>
<React.Suspense fallback={<Loading>{t("Loading")}</Loading>}>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<ColorPicker
color={color}
onChange={(color) => 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;

View File

@ -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<InputProps, "onChange"> & {
value: string | undefined;
onChange: (value: string) => void;
};
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
placement: "bottom-end",
});
return (
<Relative>
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="#"
maxLength={7}
{...rest}
/>
<MenuButton {...menu}>
{(props) => (
<SwatchButton
aria-label={t("Show menu")}
{...props}
$background={value}
/>
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Select a color")}>
<React.Suspense
fallback={
<DelayedMount>
<Text>{t("Loading")}</Text>
</DelayedMount>
}
>
<StyledColorPicker
disableAlpha
color={value}
onChange={(color) => onChange(color.hex)}
/>
</React.Suspense>
</ContextMenu>
</Relative>
);
};
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;

View File

@ -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);

View File

@ -52,7 +52,7 @@ const ListItem = (
$border={border}
$small={small}
activeStyle={{
background: theme.primary,
background: theme.accent,
}}
{...rest}
as={NavLink}

View File

@ -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;

View File

@ -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;
};

View File

@ -123,7 +123,7 @@ const Input = styled.input`
height: 32px;
&:focus {
outline-color: ${(props) => props.theme.primary};
outline-color: ${(props) => props.theme.accent};
}
`;

View File

@ -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;

View File

@ -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 {

View File

@ -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(

View File

@ -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<CustomTheme> = {}) {
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;
}

View File

@ -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

View File

@ -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`

View File

@ -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<T extends keyof TeamPreferences>(
key: T,
fallback = false
): TeamPreferences[T] | false {
return this.preferences?.[key] ?? fallback;
}

View File

@ -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<CustomTheme>;
};
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(/\/$/, "")}
/>
</Helmet>
<Layout title={response.document.title} sidebar={sidebar}>
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
readOnly
/>
</Layout>
<ThemeProvider theme={theme}>
<Layout title={response.document.title} sidebar={sidebar}>
<Document
abilities={EMPTY_OBJECT}
document={response.document}
sharedTree={response.sharedTree}
shareId={shareId}
readOnly
/>
</Layout>
</ThemeProvider>
</>
);
}

View File

@ -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};
}
`;

View File

@ -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<HTMLFormElement>(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<string | null>(
team.defaultCollectionId
);
const customTheme: Partial<CustomTheme> = 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 (
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
<Heading>{t("Details")}</Heading>
<Text type="secondary">
<Trans>
These settings affect the way that your knowledge base appears to
everyone on the team.
</Trans>
</Text>
<ThemeProvider theme={newTheme}>
<Scene title={t("Details")} icon={<TeamIcon color="currentColor" />}>
<Heading>{t("Details")}</Heading>
<Text type="secondary">
<Trans>
These settings affect the way that your knowledge base appears to
everyone on the team.
</Trans>
</Text>
<form onSubmit={handleSubmit} ref={form}>
<SettingRow
label={t("Logo")}
name="avatarUrl"
description={t(
"The logo is displayed at the top left of the application."
)}
>
<ImageInput
onSuccess={handleAvatarUpload}
onError={handleAvatarError}
model={team}
borderRadius={0}
/>
</SettingRow>
<SettingRow
label={t("Name")}
name="name"
description={t(
"The workspace name, usually the same as your company name."
)}
>
<Input
id="name"
autoComplete="organization"
value={name}
onChange={handleNameChange}
required
/>
</SettingRow>
<SettingRow
visible={env.SUBDOMAINS_ENABLED && isCloudHosted}
label={t("Subdomain")}
name="subdomain"
description={
subdomain ? (
<>
<Trans>Your knowledge base will be accessible at</Trans>{" "}
<strong>
{subdomain}.{getBaseDomain()}
</strong>
</>
) : (
t("Choose a subdomain to enable a login page just for your team.")
)
}
>
<Input
id="subdomain"
value={subdomain || ""}
onChange={handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
/>
</SettingRow>
<SettingRow
border={false}
label={t("Start view")}
name="defaultCollectionId"
description={t(
"This is the screen that workspace members will first see when they sign in."
)}
>
<DefaultCollectionInputSelect
id="defaultCollectionId"
onSelectCollection={onSelectCollection}
defaultCollectionId={defaultCollectionId}
/>
</SettingRow>
<form onSubmit={handleSubmit} ref={form}>
<Heading as="h2">{t("Display")}</Heading>
<SettingRow
label={t("Logo")}
name="avatarUrl"
description={t(
"The logo is displayed at the top left of the application."
)}
>
<ImageInput
onSuccess={handleAvatarUpload}
onError={handleAvatarError}
model={team}
borderRadius={0}
/>
</SettingRow>
<SettingRow
label={t("Name")}
name="name"
description={t(
"The workspace name, usually the same as your company name."
)}
>
<Input
id="name"
autoComplete="organization"
value={name}
onChange={handleNameChange}
required
/>
</SettingRow>
<SettingRow
border={false}
label={t("Theme")}
name="accent"
description={t("Customize the interface look and feel.")}
>
<InputColor
id="accent"
value={accent ?? theme.accent}
label={t("Accent color")}
onChange={setAccent}
flex
/>
<InputColor
id="accentText"
value={accentText ?? theme.accentText}
label={t("Accent text color")}
onChange={setAccentText}
flex
/>
</SettingRow>
<Button type="submit" disabled={auth.isSaving || !isValid}>
{auth.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
<Heading as="h2">{t("Behavior")}</Heading>
<SettingRow
visible={env.SUBDOMAINS_ENABLED && isCloudHosted}
label={t("Subdomain")}
name="subdomain"
description={
subdomain ? (
<>
<Trans>Your knowledge base will be accessible at</Trans>{" "}
<strong>
{subdomain}.{getBaseDomain()}
</strong>
</>
) : (
t(
"Choose a subdomain to enable a login page just for your team."
)
)
}
>
<Input
id="subdomain"
value={subdomain || ""}
onChange={handleSubdomainChange}
autoComplete="off"
minLength={4}
maxLength={32}
/>
</SettingRow>
<SettingRow
border={false}
label={t("Start view")}
name="defaultCollectionId"
description={t(
"This is the screen that workspace members will first see when they sign in."
)}
>
<DefaultCollectionInputSelect
id="defaultCollectionId"
onSelectCollection={onSelectCollection}
defaultCollectionId={defaultCollectionId}
/>
</SettingRow>
<Button type="submit" disabled={auth.isSaving || !isValid}>
{auth.isSaving ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
</ThemeProvider>
);
}

View File

@ -166,7 +166,7 @@ function Security() {
>
<Flex align="center">
<CheckboxIcon
color={provider.isActive ? theme.primary : undefined}
color={provider.isActive ? theme.accent : undefined}
checked={provider.isActive}
/>{" "}
<Text type="secondary">

View File

@ -37,7 +37,7 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
[FileOperationState.Creating]: <Spinner />,
[FileOperationState.Uploading]: <Spinner />,
[FileOperationState.Expired]: <ArchiveIcon color={theme.textTertiary} />,
[FileOperationState.Complete]: <DoneIcon color={theme.primary} />,
[FileOperationState.Complete]: <DoneIcon color={theme.accent} />,
[FileOperationState.Error]: <WarningIcon color={theme.danger} />,
};

View File

@ -63,7 +63,7 @@ function SharesTable({ canManage, ...rest }: Props) {
Cell: observer(({ value }: { value: string }) =>
value ? (
<Flex align="center">
<CheckmarkIcon color={theme.primary} />
<CheckmarkIcon color={theme.accent} />
</Flex>
) : null
),
@ -89,7 +89,7 @@ function SharesTable({ canManage, ...rest }: Props) {
}
: undefined,
].filter((i) => i),
[t, theme.primary, canManage]
[t, theme.accent, canManage]
);
return <TableFromParams columns={columns} {...rest} />;

View File

@ -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<CustomTheme>;
hostname?: string;
providers: Provider[];
};

View File

@ -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;

View File

@ -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]);
}
}
}

View File

@ -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<string, unknown>;
@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 = <T extends keyof TeamPreferences>(
preference: T,
value: TeamPreferences[T]
) => {
if (!this.preferences) {
this.preferences = {};
}

View File

@ -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,

View File

@ -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,
};
}

View File

@ -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,

View File

@ -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

View File

@ -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());

View File

@ -0,0 +1 @@
export { default } from "./teams";

View File

@ -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<typeof TeamsUpdateSchema>;

View File

@ -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<T.TeamsUpdateSchemaReq>) => {
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,

View File

@ -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"

View File

@ -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</2>.": "Choose the interface language. Community translations are accepted though our <2>translation portal</2>.",
"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",

View File

@ -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<Props>`
${styledNormalize}
@ -108,7 +110,7 @@ export default createGlobalStyle<Props>`
}
.js-focus-visible .focus-visible {
outline-color: ${(props) => props.theme.primary};
outline-color: ${(props) => props.theme.accent};
outline-offset: -1px;
}
`;

View File

@ -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<Colors>) => {
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<Colors>): 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<Colors>): 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<Colors>) => {
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;

View File

@ -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<CustomTheme>;
};
export enum NavigationNodeType {
Collection = "collection",