diff --git a/README.md b/README.md index 33481fab2..da2636cc2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

An open, extensible, wiki for your team built using React and Node.js.
Try out Outline using our hosted version at www.getoutline.com.

- Outline + Outline

diff --git a/app.json b/app.json index 5aa7e9909..4af94bb08 100644 --- a/app.json +++ b/app.json @@ -30,6 +30,10 @@ "postdeploy": "yarn sequelize db:migrate" }, "env": { + "NODE_ENV": { + "value": "production", + "required": true + }, "SECRET_KEY": { "description": "A secret key", "generator": "secret", @@ -144,4 +148,4 @@ "required": false } } -} \ No newline at end of file +} diff --git a/app/components/Arrow.js b/app/components/Arrow.js new file mode 100644 index 000000000..2b6ade160 --- /dev/null +++ b/app/components/Arrow.js @@ -0,0 +1,23 @@ +// @flow +import * as React from "react"; + +export default function Arrow() { + return ( + + + + + ); +} diff --git a/app/components/CenteredContent.js b/app/components/CenteredContent.js index 0a868cc56..b25801640 100644 --- a/app/components/CenteredContent.js +++ b/app/components/CenteredContent.js @@ -3,17 +3,18 @@ import * as React from "react"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -type Props = { +type Props = {| children?: React.Node, -}; + withStickyHeader?: boolean, +|}; const Container = styled.div` width: 100%; max-width: 100vw; - padding: 60px 20px; + padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")}; ${breakpoint("tablet")` - padding: 60px; + padding: ${(props) => (props.withStickyHeader ? "4px 60px" : "60px")}; `}; `; diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index 2292f45fd..b5f65ba62 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -2,8 +2,9 @@ import { sortBy, keyBy } from "lodash"; import { observer, inject } from "mobx-react"; import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import { MAX_AVATAR_DISPLAY } from "shared/constants"; - import DocumentPresenceStore from "stores/DocumentPresenceStore"; import ViewsStore from "stores/ViewsStore"; import Document from "models/Document"; @@ -51,7 +52,7 @@ class Collaborators extends React.Component { const overflow = documentViews.length - mostRecentViewers.length; return ( - v.user)} overflow={overflow} renderAvatar={(user) => { @@ -75,4 +76,10 @@ class Collaborators extends React.Component { } } +const FacepileHiddenOnMobile = styled(Facepile)` + ${breakpoint("mobile", "tablet")` + display: none; + `}; +`; + export default inject("views", "presence")(Collaborators); diff --git a/app/components/CollectionDescription.js b/app/components/CollectionDescription.js new file mode 100644 index 000000000..1e13b461a --- /dev/null +++ b/app/components/CollectionDescription.js @@ -0,0 +1,212 @@ +// @flow +import { observer } from "mobx-react"; +import { transparentize } from "polished"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Collection from "models/Collection"; +import Arrow from "components/Arrow"; +import ButtonLink from "components/ButtonLink"; +import Editor from "components/Editor"; +import LoadingIndicator from "components/LoadingIndicator"; +import NudeButton from "components/NudeButton"; +import useDebouncedCallback from "hooks/useDebouncedCallback"; +import useStores from "hooks/useStores"; + +type Props = {| + collection: Collection, +|}; + +function CollectionDescription({ collection }: Props) { + const { collections, ui, policies } = useStores(); + const { t } = useTranslation(); + const [isExpanded, setExpanded] = React.useState(false); + const [isEditing, setEditing] = React.useState(false); + const [isDirty, setDirty] = React.useState(false); + const can = policies.abilities(collection.id); + + const handleStartEditing = React.useCallback(() => { + setEditing(true); + }, []); + + const handleStopEditing = React.useCallback(() => { + setEditing(false); + }, []); + + const handleClickDisclosure = React.useCallback( + (event) => { + event.preventDefault(); + + if (isExpanded && document.activeElement) { + document.activeElement.blur(); + } + + setExpanded(!isExpanded); + }, + [isExpanded] + ); + + const handleSave = useDebouncedCallback(async (getValue) => { + try { + await collection.save({ + description: getValue(), + }); + setDirty(false); + } catch (err) { + ui.showToast( + t("Sorry, an error occurred saving the collection", { + type: "error", + }) + ); + throw err; + } + }, 1000); + + const handleChange = React.useCallback( + (getValue) => { + setDirty(true); + handleSave(getValue); + }, + [handleSave] + ); + + React.useEffect(() => { + setEditing(false); + }, [collection.id]); + + const placeholder = `${t("Add a description")}…`; + const key = isEditing || isDirty ? "draft" : collection.updatedAt; + + return ( + + + + {collections.isSaving && } + {collection.hasDescription || isEditing || isDirty ? ( + Loading…}> + + + ) : ( + can.update && {placeholder} + )} + + + {!isEditing && ( + + + + )} + + ); +} + +const Disclosure = styled(NudeButton)` + opacity: 0; + color: ${(props) => props.theme.divider}; + position: absolute; + top: calc(25vh - 50px); + left: 50%; + z-index: 1; + transform: rotate(-90deg) translateX(-50%); + transition: opacity 100ms ease-in-out; + + &:focus, + &:hover { + opacity: 1; + } + + &:active { + color: ${(props) => props.theme.sidebarText}; + } +`; + +const Placeholder = styled(ButtonLink)` + color: ${(props) => props.theme.placeholder}; + cursor: text; + min-height: 27px; +`; + +const MaxHeight = styled.div` + position: relative; + max-height: 25vh; + overflow: hidden; + margin: -8px; + padding: 8px; + + &[data-editing="true"], + &[data-expanded="true"] { + max-height: initial; + overflow: initial; + + ${Disclosure} { + top: initial; + bottom: 0; + transform: rotate(90deg) translateX(-50%); + } + } + + &:hover ${Disclosure} { + opacity: 1; + } +`; + +const Input = styled.div` + margin: -8px; + padding: 8px; + border-radius: 8px; + transition: ${(props) => props.theme.backgroundTransition}; + + &:after { + content: ""; + position: absolute; + top: calc(25vh - 50px); + left: 0; + right: 0; + height: 50px; + pointer-events: none; + background: linear-gradient( + 180deg, + ${(props) => transparentize(1, props.theme.background)} 0%, + ${(props) => props.theme.background} 100% + ); + } + + &[data-editing="true"], + &[data-expanded="true"] { + &:after { + background: transparent; + } + } + + &[data-editing="true"] { + background: ${(props) => props.theme.secondaryBackground}; + } + + .block-menu-trigger, + .heading-anchor { + display: none !important; + } +`; + +export default observer(CollectionDescription); diff --git a/app/components/DocumentListItem.js b/app/components/DocumentListItem.js index 78fe61d54..1a52d4f6c 100644 --- a/app/components/DocumentListItem.js +++ b/app/components/DocumentListItem.js @@ -163,8 +163,11 @@ const DocumentLink = styled(Link)` padding: 6px 8px; border-radius: 8px; max-height: 50vh; - min-width: 100%; - max-width: calc(100vw - 40px); + width: calc(100vw - 8px); + + ${breakpoint("tablet")` + width: auto; + `}; ${Actions} { opacity: 0; diff --git a/app/components/Editor.js b/app/components/Editor.js index 103d87777..37d01c5f8 100644 --- a/app/components/Editor.js +++ b/app/components/Editor.js @@ -27,13 +27,16 @@ export type Props = {| autoFocus?: boolean, template?: boolean, placeholder?: string, + maxLength?: number, scrollTo?: string, + handleDOMEvents?: Object, readOnlyWriteCheckboxes?: boolean, onBlur?: (event: SyntheticEvent<>) => any, onFocus?: (event: SyntheticEvent<>) => any, onPublish?: (event: SyntheticEvent<>) => any, onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any, onCancel?: () => any, + onDoubleClick?: () => any, onChange?: (getValue: () => string) => any, onSearchLink?: (title: string) => any, onHoverLink?: (event: MouseEvent) => any, @@ -177,7 +180,7 @@ const StyledEditor = styled(RichMarkdownEditor)` justify-content: start; > div { - transition: ${(props) => props.theme.backgroundTransition}; + background: transparent; } & * { diff --git a/app/components/Header.js b/app/components/Header.js new file mode 100644 index 000000000..9a0e67908 --- /dev/null +++ b/app/components/Header.js @@ -0,0 +1,104 @@ +// @flow +import { throttle } from "lodash"; +import { observer } from "mobx-react"; +import { transparentize } from "polished"; +import * as React from "react"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import Fade from "components/Fade"; +import Flex from "components/Flex"; + +type Props = {| + breadcrumb?: React.Node, + title: React.Node, + actions?: React.Node, +|}; + +function Header({ breadcrumb, title, actions }: Props) { + const [isScrolled, setScrolled] = React.useState(false); + + const handleScroll = React.useCallback( + throttle(() => setScrolled(window.scrollY > 75), 50), + [] + ); + + React.useEffect(() => { + window.addEventListener("scroll", handleScroll); + + return () => window.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + const handleClickTitle = React.useCallback(() => { + window.scrollTo({ + top: 0, + behavior: "smooth", + }); + }, []); + + return ( + + {breadcrumb} + {isScrolled ? ( + + <Fade> + <Flex align="center">{title}</Flex> + </Fade> + + ) : ( +

+ )} + {actions && {actions}} + + ); +} + +const Wrapper = styled(Flex)` + position: sticky; + top: 0; + right: 0; + left: 0; + z-index: 2; + background: ${(props) => transparentize(0.2, props.theme.background)}; + padding: 12px; + transition: all 100ms ease-out; + transform: translate3d(0, 0, 0); + backdrop-filter: blur(20px); + + @media print { + display: none; + } + + ${breakpoint("tablet")` + padding: ${(props) => (props.isCompact ? "12px" : `24px 24px 0`)}; + `}; +`; + +const Title = styled(Flex)` + font-size: 16px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + cursor: pointer; + width: 0; + + ${breakpoint("tablet")` + flex-grow: 1; + `}; +`; + +const Actions = styled(Flex)` + align-self: flex-end; + height: 32px; +`; + +export default observer(Header); diff --git a/app/components/Input.js b/app/components/Input.js index 855f59b49..8ca02b24c 100644 --- a/app/components/Input.js +++ b/app/components/Input.js @@ -4,6 +4,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; import Flex from "components/Flex"; const RealTextarea = styled.textarea` @@ -33,6 +34,10 @@ const RealInput = styled.input` &::placeholder { color: ${(props) => props.theme.placeholder}; } + + ${breakpoint("mobile", "tablet")` + font-size: 16px; + `}; `; const Wrapper = styled.div` diff --git a/app/components/Layout.js b/app/components/Layout.js index 74b90bea7..0615e4da6 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -7,7 +7,7 @@ import { Helmet } from "react-helmet"; import { withTranslation, type TFunction } from "react-i18next"; import keydown from "react-keydown"; import { Switch, Route, Redirect } from "react-router-dom"; -import styled, { withTheme } from "styled-components"; +import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; import AuthStore from "stores/AuthStore"; import DocumentsStore from "stores/DocumentsStore"; @@ -24,7 +24,6 @@ import Sidebar from "components/Sidebar"; import SettingsSidebar from "components/Sidebar/Settings"; import SkipNavContent from "components/SkipNavContent"; import SkipNavLink from "components/SkipNavLink"; -import { type Theme } from "types"; import { meta } from "utils/keyboard"; import { homeUrl, @@ -40,7 +39,6 @@ type Props = { auth: AuthStore, ui: UiStore, notifications?: React.Node, - theme: Theme, i18n: Object, t: TFunction, }; @@ -51,24 +49,12 @@ class Layout extends React.Component { @observable redirectTo: ?string; @observable keyboardShortcutsOpen: boolean = false; - constructor(props: Props) { - super(); - this.updateBackground(props); - } - componentDidUpdate() { - this.updateBackground(this.props); - if (this.redirectTo) { this.redirectTo = undefined; } } - updateBackground(props: Props) { - // ensure the wider page color always matches the theme - window.document.body.style.background = props.theme.background; - } - @keydown(`${meta}+.`) handleToggleSidebar() { this.props.ui.toggleCollapsedSidebar(); @@ -212,5 +198,5 @@ const Content = styled(Flex)` `; export default withTranslation()( - inject("auth", "ui", "documents")(withTheme(Layout)) + inject("auth", "ui", "documents")(Layout) ); diff --git a/app/components/PageTheme.js b/app/components/PageTheme.js new file mode 100644 index 000000000..2914f78b2 --- /dev/null +++ b/app/components/PageTheme.js @@ -0,0 +1,35 @@ +// @flow +import * as React from "react"; +import { useTheme } from "styled-components"; +import useStores from "hooks/useStores"; + +export default function PageTheme() { + const { ui } = useStores(); + const theme = useTheme(); + + React.useEffect(() => { + // wider page background beyond the React root + if (document.body) { + document.body.style.background = theme.background; + } + + // theme-color adjusts the title bar color for desktop PWA + const themeElement = document.querySelector('meta[name="theme-color"]'); + if (themeElement) { + themeElement.setAttribute("content", theme.background); + } + + // status bar color for iOS PWA + const statusElement = document.querySelector( + 'meta[name="apple-mobile-web-app-status-bar-style"]' + ); + if (statusElement) { + statusElement.setAttribute( + "content", + ui.resolvedTheme === "dark" ? "black-translucent" : "default" + ); + } + }, [theme, ui.resolvedTheme]); + + return null; +} diff --git a/app/components/Scene.js b/app/components/Scene.js new file mode 100644 index 000000000..eaf31732b --- /dev/null +++ b/app/components/Scene.js @@ -0,0 +1,50 @@ +// @flow +import * as React from "react"; +import styled from "styled-components"; +import CenteredContent from "components/CenteredContent"; +import Header from "components/Header"; +import PageTitle from "components/PageTitle"; + +type Props = {| + icon?: React.Node, + title: React.Node, + textTitle?: string, + children: React.Node, + breadcrumb?: React.Node, + actions?: React.Node, +|}; + +function Scene({ + title, + icon, + textTitle, + actions, + breadcrumb, + children, +}: Props) { + return ( + + +
+ {icon} {title} + + ) : ( + title + ) + } + actions={actions} + breadcrumb={breadcrumb} + /> + {children} + + ); +} + +const FillWidth = styled.div` + width: 100%; +`; + +export default Scene; diff --git a/app/components/Sidebar/Main.js b/app/components/Sidebar/Main.js index d66967256..8c948ccac 100644 --- a/app/components/Sidebar/Main.js +++ b/app/components/Sidebar/Main.js @@ -22,9 +22,9 @@ import Modal from "components/Modal"; import Scrollable from "components/Scrollable"; import Sidebar from "./Sidebar"; import Collections from "./components/Collections"; -import HeaderBlock from "./components/HeaderBlock"; import Section from "./components/Section"; import SidebarLink from "./components/SidebarLink"; +import TeamButton from "./components/TeamButton"; import useStores from "hooks/useStores"; import AccountMenu from "menus/AccountMenu"; @@ -72,7 +72,7 @@ function MainSidebar() { {(props) => ( - - {t("Return to App")} diff --git a/app/components/Sidebar/components/HeaderBlock.js b/app/components/Sidebar/components/TeamButton.js similarity index 88% rename from app/components/Sidebar/components/HeaderBlock.js rename to app/components/Sidebar/components/TeamButton.js index ee8e40e46..9a9206dd1 100644 --- a/app/components/Sidebar/components/HeaderBlock.js +++ b/app/components/Sidebar/components/TeamButton.js @@ -13,7 +13,7 @@ type Props = {| logoUrl: string, |}; -const HeaderBlock = React.forwardRef( +const TeamButton = React.forwardRef( ({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
@@ -25,8 +25,7 @@ const HeaderBlock = React.forwardRef( /> - {teamName}{" "} - {showDisclosure && } + {teamName} {showDisclosure && } {subheading} @@ -35,7 +34,7 @@ const HeaderBlock = React.forwardRef( ) ); -const StyledExpandedIcon = styled(ExpandedIcon)` +const Disclosure = styled(ExpandedIcon)` position: absolute; right: 0; top: 0; @@ -84,4 +83,4 @@ const Header = styled.button` } `; -export default HeaderBlock; +export default TeamButton; diff --git a/app/components/Sidebar/components/Toggle.js b/app/components/Sidebar/components/Toggle.js index 4533c5206..86062e31a 100644 --- a/app/components/Sidebar/components/Toggle.js +++ b/app/components/Sidebar/components/Toggle.js @@ -2,6 +2,7 @@ import * as React from "react"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; +import Arrow from "components/Arrow"; type Props = { direction: "left" | "right", @@ -14,22 +15,7 @@ const Toggle = React.forwardRef( return ( - - - - + ); @@ -60,6 +46,7 @@ export const ToggleButton = styled.button` `; export const Positioner = styled.div` + display: none; z-index: 2; position: absolute; top: 0; @@ -70,6 +57,10 @@ export const Positioner = styled.div` &:hover ${ToggleButton}, &:focus-within ${ToggleButton} { opacity: 1; } + + ${breakpoint("tablet")` + display: block; + `} `; export default Toggle; diff --git a/app/components/Subheading.js b/app/components/Subheading.js index c0716deb5..eecb044ab 100644 --- a/app/components/Subheading.js +++ b/app/components/Subheading.js @@ -2,19 +2,17 @@ import * as React from "react"; import styled from "styled-components"; -type Props = { +type Props = {| children: React.Node, -}; +|}; const H3 = styled.h3` border-bottom: 1px solid ${(props) => props.theme.divider}; - margin-top: 22px; - margin-bottom: 12px; + margin: 12px 0; line-height: 1; - position: relative; `; -const Underline = styled("span")` +const Underline = styled.div` margin-top: -1px; display: inline-block; font-weight: 500; @@ -22,14 +20,29 @@ const Underline = styled("span")` line-height: 1.5; color: ${(props) => props.theme.textSecondary}; border-bottom: 3px solid ${(props) => props.theme.textSecondary}; - padding-bottom: 5px; + padding-top: 6px; + padding-bottom: 4px; +`; + +// When sticky we need extra background coverage around the sides otherwise +// items that scroll past can "stick out" the sides of the heading +const Sticky = styled.div` + position: sticky; + top: 54px; + margin: 0 -8px; + padding: 0 8px; + background: ${(props) => props.theme.background}; + transition: ${(props) => props.theme.backgroundTransition}; + z-index: 1; `; const Subheading = ({ children, ...rest }: Props) => { return ( -

- {children} -

+ +

+ {children} +

+
); }; diff --git a/app/components/Tab.js b/app/components/Tab.js index c616e0a3e..5d7717445 100644 --- a/app/components/Tab.js +++ b/app/components/Tab.js @@ -8,7 +8,7 @@ type Props = { theme: Theme, }; -const StyledNavLink = styled(NavLink)` +const TabLink = styled(NavLink)` position: relative; display: inline-flex; align-items: center; @@ -16,7 +16,7 @@ const StyledNavLink = styled(NavLink)` font-size: 14px; color: ${(props) => props.theme.textTertiary}; margin-right: 24px; - padding-bottom: 8px; + padding: 6px 0; &:hover { color: ${(props) => props.theme.textSecondary}; @@ -32,7 +32,7 @@ function Tab({ theme, ...rest }: Props) { color: theme.textSecondary, }; - return ; + return ; } export default withTheme(Tab); diff --git a/app/components/Tabs.js b/app/components/Tabs.js index 5ce46c3db..1c6c5b482 100644 --- a/app/components/Tabs.js +++ b/app/components/Tabs.js @@ -1,16 +1,27 @@ // @flow +import * as React from "react"; import styled from "styled-components"; -const Tabs = styled.nav` - position: relative; +const Nav = styled.nav` border-bottom: 1px solid ${(props) => props.theme.divider}; - margin-top: 22px; - margin-bottom: 12px; + margin: 12px 0; overflow-y: auto; white-space: nowrap; transition: opacity 250ms ease-out; `; +// When sticky we need extra background coverage around the sides otherwise +// items that scroll past can "stick out" the sides of the heading +const Sticky = styled.div` + position: sticky; + top: 54px; + margin: 0 -8px; + padding: 0 8px; + background: ${(props) => props.theme.background}; + transition: ${(props) => props.theme.backgroundTransition}; + z-index: 1; +`; + export const Separator = styled.span` border-left: 1px solid ${(props) => props.theme.divider}; position: relative; @@ -19,4 +30,12 @@ export const Separator = styled.span` margin-top: 6px; `; +const Tabs = (props: {}) => { + return ( + + + + ); +}; + export default Tabs; diff --git a/app/components/Toasts/components/Toast.js b/app/components/Toast.js similarity index 100% rename from app/components/Toasts/components/Toast.js rename to app/components/Toast.js diff --git a/app/components/Toasts/Toasts.js b/app/components/Toasts.js similarity index 94% rename from app/components/Toasts/Toasts.js rename to app/components/Toasts.js index c82bedea3..df2502fdc 100644 --- a/app/components/Toasts/Toasts.js +++ b/app/components/Toasts.js @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import styled from "styled-components"; -import Toast from "./components/Toast"; +import Toast from "components/Toast"; import useStores from "hooks/useStores"; function Toasts() { diff --git a/app/components/Toasts/index.js b/app/components/Toasts/index.js deleted file mode 100644 index 13373bf82..000000000 --- a/app/components/Toasts/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Toasts from "./Toasts"; -export default Toasts; diff --git a/app/hooks/useDebouncedCallback.js b/app/hooks/useDebouncedCallback.js new file mode 100644 index 000000000..9ba68d327 --- /dev/null +++ b/app/hooks/useDebouncedCallback.js @@ -0,0 +1,31 @@ +// @flow +import * as React from "react"; + +export default function useDebouncedCallback( + callback: (any) => mixed, + wait: number +) { + // track args & timeout handle between calls + const argsRef = React.useRef(); + const timeout = React.useRef(); + + function cleanup() { + if (timeout.current) { + clearTimeout(timeout.current); + } + } + + // make sure our timeout gets cleared if consuming component gets unmounted + React.useEffect(() => cleanup, []); + + return function (...args: any) { + argsRef.current = args; + cleanup(); + + timeout.current = setTimeout(() => { + if (argsRef.current) { + callback(...argsRef.current); + } + }, wait); + }; +} diff --git a/app/index.js b/app/index.js index c991c9cdc..3fab57f9a 100644 --- a/app/index.js +++ b/app/index.js @@ -10,6 +10,7 @@ import { Router } from "react-router-dom"; import { initI18n } from "shared/i18n"; import stores from "stores"; import ErrorBoundary from "components/ErrorBoundary"; +import PageTheme from "components/PageTheme"; import ScrollToTop from "components/ScrollToTop"; import Theme from "components/Theme"; import Toasts from "components/Toasts"; @@ -19,13 +20,28 @@ import { initSentry } from "utils/sentry"; initI18n(); -const element = document.getElementById("root"); +const element = window.document.getElementById("root"); const history = createBrowserHistory(); if (env.SENTRY_DSN) { initSentry(history); } +if ("serviceWorker" in window.navigator) { + window.addEventListener("load", () => { + window.navigator.serviceWorker + .register("/static/service-worker.js", { + scope: "/", + }) + .then((registration) => { + console.log("SW registered: ", registration); + }) + .catch((registrationError) => { + console.log("SW registration failed: ", registrationError); + }); + }); +} + if (element) { render( @@ -34,6 +50,7 @@ if (element) { <> + diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js index 7a1ef1758..2168bac50 100644 --- a/app/routes/authenticated.js +++ b/app/routes/authenticated.js @@ -3,11 +3,11 @@ import * as React from "react"; import { Switch, Redirect, type Match } from "react-router-dom"; import Archive from "scenes/Archive"; import Collection from "scenes/Collection"; -import Dashboard from "scenes/Dashboard"; import KeyedDocument from "scenes/Document/KeyedDocument"; import DocumentNew from "scenes/DocumentNew"; import Drafts from "scenes/Drafts"; import Error404 from "scenes/Error404"; +import Home from "scenes/Home"; import Search from "scenes/Search"; import Starred from "scenes/Starred"; import Templates from "scenes/Templates"; @@ -37,8 +37,8 @@ export default function AuthenticatedRoutes() { - - + + diff --git a/app/scenes/Archive.js b/app/scenes/Archive.js index 25394cec3..3290ff71e 100644 --- a/app/scenes/Archive.js +++ b/app/scenes/Archive.js @@ -20,7 +20,7 @@ function Archive(props: Props) { const { documents } = props; return ( - + {t("Archive")} { @observable isFetching: boolean = true; @observable permissionsModalOpen: boolean = false; @observable editModalOpen: boolean = false; - @observable redirectTo: ?string; componentDidMount() { const { id } = this.props.match.params; @@ -108,14 +103,6 @@ class CollectionScene extends React.Component { } }; - onNewDocument = (ev: SyntheticEvent<>) => { - ev.preventDefault(); - - if (this.collection) { - this.redirectTo = newDocumentUrl(this.collection.id); - } - }; - onPermissions = (ev: SyntheticEvent<>) => { ev.preventDefault(); this.permissionsModalOpen = true; @@ -138,7 +125,7 @@ class CollectionScene extends React.Component { const can = policies.abilities(match.params.id || ""); return ( - + <> {can.update && ( <> @@ -157,7 +144,12 @@ class CollectionScene extends React.Component { delay={500} placement="bottom" > - @@ -181,14 +173,13 @@ class CollectionScene extends React.Component { )} /> - + ); } render() { - const { documents, theme, t } = this.props; + const { documents, t } = this.props; - if (this.redirectTo) return ; if (!this.isFetching && !this.collection) return ; const pinnedDocuments = this.collection @@ -197,181 +188,171 @@ class CollectionScene extends React.Component { const collection = this.collection; const collectionName = collection ? collection.name : ""; const hasPinnedDocuments = !!pinnedDocuments.length; - const hasDescription = collection ? collection.hasDescription : false; - return ( - - {collection ? ( + return collection ? ( + - - {collection.isEmpty ? ( - - - }} - /> -
- Get started by creating a new one! -
- - - - -    - {collection.private && ( - - )} - - - - - - - -
- ) : ( - <> - - {" "} - {collection.name} - - - {hasDescription && ( - Loading…

}> - -
- )} - - {hasPinnedDocuments && ( - <> - - {t("Pinned")} - - - - )} - - - - {t("Documents")} - - - {t("Recently updated")} - - - {t("Recently published")} - - - {t("Least recently updated")} - - - {t("A–Z")} - - - - - - - - - - - - - - - - - - - - - - - - )} - - {this.renderActions()} + +   + {collection.name} + } + actions={this.renderActions()} + > + {collection.isEmpty ? ( + + + }} + /> +
+ Get started by creating a new one! +
+ + + + +    + {collection.private && ( + + )} + + + + + + + +
) : ( <> - + {" "} + {collection.name} - + + + {hasPinnedDocuments && ( + <> + + {t("Pinned")} + + + + )} + + + + {t("Documents")} + + + {t("Recently updated")} + + + {t("Recently published")} + + + {t("Least recently updated")} + + + {t("A–Z")} + + + + + + + + + + + + + + + + + + + + + + )} +
+ ) : ( + + + + + ); } @@ -390,16 +371,11 @@ const TinyPinIcon = styled(PinIcon)` opacity: 0.8; `; -const Wrapper = styled(Flex)` +const Empty = styled(Flex)` justify-content: center; margin: 10px 0; `; export default withTranslation()( - inject( - "collections", - "policies", - "documents", - "ui" - )(withTheme(CollectionScene)) + inject("collections", "policies", "documents", "ui")(CollectionScene) ); diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index 84ede8337..a759821ad 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -11,7 +11,6 @@ import Flex from "components/Flex"; import HelpText from "components/HelpText"; import IconPicker from "components/IconPicker"; import Input from "components/Input"; -import InputRich from "components/InputRich"; import InputSelect from "components/InputSelect"; import Switch from "components/Switch"; @@ -27,7 +26,6 @@ type Props = { class CollectionEdit extends React.Component { @observable name: string = this.props.collection.name; @observable sharing: boolean = this.props.collection.sharing; - @observable description: string = this.props.collection.description; @observable icon: string = this.props.collection.icon; @observable color: string = this.props.collection.color || "#4E5C6E"; @observable private: boolean = this.props.collection.private; @@ -43,7 +41,6 @@ class CollectionEdit extends React.Component { try { await this.props.collection.save({ name: this.name, - description: this.description, icon: this.icon, color: this.color, private: this.private, @@ -69,10 +66,6 @@ class CollectionEdit extends React.Component { } }; - handleDescriptionChange = (getValue: () => string) => { - this.description = getValue(); - }; - handleNameChange = (ev: SyntheticInputEvent<*>) => { this.name = ev.target.value; }; @@ -120,15 +113,6 @@ class CollectionEdit extends React.Component { icon={this.icon} /> - { @observable name: string = ""; - @observable description: string = ""; @observable icon: string = ""; @observable color: string = "#4E5C6E"; @observable sharing: boolean = true; @@ -43,7 +41,6 @@ class CollectionNew extends React.Component { const collection = new Collection( { name: this.name, - description: this.description, sharing: this.sharing, icon: this.icon, color: this.color, @@ -90,10 +87,6 @@ class CollectionNew extends React.Component { this.hasOpenedIconPicker = true; }; - handleDescriptionChange = (getValue: () => string) => { - this.description = getValue(); - }; - handlePrivateChange = (ev: SyntheticInputEvent) => { this.private = ev.target.checked; }; @@ -115,9 +108,9 @@ class CollectionNew extends React.Component {
- Collections are for grouping your knowledge base. They work best - when organized around a topic or internal team — Product or - Engineering for example. + Collections are for grouping your documents. They work best when + organized around a topic or internal team — Product or Engineering + for example. @@ -138,14 +131,6 @@ class CollectionNew extends React.Component { icon={this.icon} /> - { const { documents, match } = this.props; this.document = documents.getByUrl(match.params.documentSlug); this.loadDocument(); - this.updateBackground(); } componentDidUpdate(prevProps: Props) { @@ -74,13 +71,6 @@ class DataLoader extends React.Component { ) { this.loadRevision(); } - this.updateBackground(); - } - - updateBackground() { - // ensure the wider page color always matches the theme. This is to - // account for share links which don't sit in the wider Layout component - window.document.body.style.background = this.props.theme.background; } get isEditing() { @@ -266,5 +256,5 @@ export default withRouter( "revisions", "policies", "shares" - )(withTheme(DataLoader)) + )(DataLoader) ); diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 17b9df978..f52105c98 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -480,7 +480,7 @@ const ReferencesWrapper = styled("div")` const MaxWidth = styled(Flex)` ${(props) => props.archived && `* { color: ${props.theme.textSecondary} !important; } `}; - padding: 0 16px; + padding: 0 12px; max-width: 100vw; width: 100%; diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index 7f840a91e..d3dbac372 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -1,7 +1,5 @@ // @flow -import { throttle } from "lodash"; -import { observable } from "mobx"; -import { observer, inject } from "mobx-react"; +import { observer } from "mobx-react"; import { TableOfContentsIcon, EditIcon, @@ -9,18 +7,11 @@ import { PlusIcon, MoreIcon, } from "outline-icons"; -import { transparentize, darken } from "polished"; import * as React from "react"; -import { withTranslation, Trans, type TFunction } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import AuthStore from "stores/AuthStore"; -import PoliciesStore from "stores/PoliciesStore"; -import SharesStore from "stores/SharesStore"; -import UiStore from "stores/UiStore"; import Document from "models/Document"; - import DocumentShare from "scenes/DocumentShare"; import { Action, Separator } from "components/Actions"; import Badge from "components/Badge"; @@ -28,20 +19,17 @@ import Breadcrumb, { Slash } from "components/Breadcrumb"; import Button from "components/Button"; import Collaborators from "components/Collaborators"; import Fade from "components/Fade"; -import Flex from "components/Flex"; +import Header from "components/Header"; import Modal from "components/Modal"; import Tooltip from "components/Tooltip"; +import useStores from "hooks/useStores"; import DocumentMenu from "menus/DocumentMenu"; import NewChildDocumentMenu from "menus/NewChildDocumentMenu"; import TemplatesMenu from "menus/TemplatesMenu"; import { metaDisplay } from "utils/keyboard"; import { newDocumentUrl, editDocumentUrl } from "utils/routeHelpers"; -type Props = { - auth: AuthStore, - ui: UiStore, - shares: SharesStore, - policies: PoliciesStore, +type Props = {| document: Document, isDraft: boolean, isEditing: boolean, @@ -56,356 +44,263 @@ type Props = { publish?: boolean, autosave?: boolean, }) => void, - t: TFunction, -}; +|}; -@observer -class Header extends React.Component { - @observable isScrolled = false; - @observable showShareModal = false; +function DocumentHeader({ + document, + isEditing, + isDraft, + isPublishing, + isRevision, + isSaving, + savingIsDisabled, + publishingIsDisabled, + onSave, +}: Props) { + const { t } = useTranslation(); + const { auth, ui, shares, policies } = useStores(); + const [showShareModal, setShowShareModal] = React.useState(false); - componentDidMount() { - window.addEventListener("scroll", this.handleScroll); - } + const handleSave = React.useCallback(() => { + onSave({ done: true }); + }, [onSave]); - componentWillUnmount() { - window.removeEventListener("scroll", this.handleScroll); - } + const handlePublish = React.useCallback(() => { + onSave({ done: true, publish: true }); + }, [onSave]); - updateIsScrolled = () => { - this.isScrolled = window.scrollY > 75; - }; + const handleShareLink = React.useCallback( + async (ev: SyntheticEvent<>) => { + await document.share(); - handleScroll = throttle(this.updateIsScrolled, 50); + setShowShareModal(true); + }, + [document] + ); - handleSave = () => { - this.props.onSave({ done: true }); - }; + const handleCloseShareModal = React.useCallback(() => { + setShowShareModal(false); + }, []); - handlePublish = () => { - this.props.onSave({ done: true, publish: true }); - }; + const share = shares.getByDocumentId(document.id); + const isPubliclyShared = share && share.published; + const isNew = document.isNew; + const isTemplate = document.isTemplate; + const can = policies.abilities(document.id); + const canShareDocument = auth.team && auth.team.sharing && can.share; + const canToggleEmbeds = auth.team && auth.team.documentEmbeds; + const canEdit = can.update && !isEditing; - handleShareLink = async (ev: SyntheticEvent<>) => { - const { document } = this.props; - await document.share(); - - this.showShareModal = true; - }; - - handleCloseShareModal = () => { - this.showShareModal = false; - }; - - handleClickTitle = () => { - window.scrollTo({ - top: 0, - behavior: "smooth", - }); - }; - - render() { - const { - shares, - document, - policies, - isEditing, - isDraft, - isPublishing, - isRevision, - isSaving, - savingIsDisabled, - publishingIsDisabled, - ui, - auth, - t, - } = this.props; - - const share = shares.getByDocumentId(document.id); - const isPubliclyShared = share && share.published; - const isNew = document.isNew; - const isTemplate = document.isTemplate; - const can = policies.abilities(document.id); - const canShareDocument = auth.team && auth.team.sharing && can.share; - const canToggleEmbeds = auth.team && auth.team.documentEmbeds; - const canEdit = can.update && !isEditing; - - return ( - + - - - - - {!isEditing && ( - <> - - - - - - )} - {isEditing && ( - <> + - - )} - {canEdit && ( - - - - - - )} - {canEdit && can.createChildDocument && ( - - ( + )} + {isEditing && ( + <> + - - )} - /> - - )} - {canEdit && isTemplate && !isDraft && !isRevision && ( - - - - )} - {can.update && isDraft && !isRevision && ( - - - - - - )} - {!isEditing && ( - <> - + + + )} + {canEdit && ( - + + + + )} + {canEdit && can.createChildDocument && ( + + ( - + )} - showToggleEmbeds={canToggleEmbeds} - showPrint /> - - )} - - - ); - } + )} + {canEdit && isTemplate && !isDraft && !isRevision && ( + + + + )} + {can.update && isDraft && !isRevision && ( + + + + + + )} + {!isEditing && ( + <> + + + ( +