mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 481605d017 | |||
| 985ba9be29 | |||
| 8412efcd0c | |||
| fb0b38fb71 | |||
| 8ff2f41068 | |||
| 334dce7984 | |||
| 61b303831f | |||
| a9d60d288e | |||
| 3f267d7745 | |||
| e845652cb8 | |||
| 7066a45323 | |||
| 654fdf1c7e | |||
| 2a5fd0b332 | |||
| 9ba63c6054 | |||
| 785e208c6c | |||
| 9d84652dff | |||
| ef6ce72cf5 | |||
| 7777cccf3b | |||
| 620e4942d8 | |||
| 91ee3e62f2 | |||
| eeb7650941 | |||
| ee57f1ccf5 | |||
| 32f0589190 | |||
| ce2b246e60 | |||
| ae13347d55 | |||
| 13205171d7 | |||
| a912ea24f6 | |||
| 6fa760688b | |||
| 2825df29de | |||
| 604e97a6ce | |||
| dc1bc44c8f | |||
| 2a55e78722 | |||
| eaf8dc5a3c | |||
| f89d5adc37 | |||
| 978a123122 | |||
| 96e65f495e | |||
| 4106f15450 | |||
| b3cd78c833 | |||
| 7b87fea4f4 | |||
| 7e9bcb0c37 | |||
| f6370ccf6d | |||
| 11e1108f4a | |||
| c9fdf48c33 | |||
| 6a206de6cd | |||
| c69b393776 | |||
| 6e9c456147 | |||
| 70626ffff0 | |||
| 993aad004e | |||
| 6fa9e700c8 | |||
| 836b2e310a | |||
| 24ecaa8ce4 | |||
| 40491fafe9 | |||
| 111212b038 | |||
| 774c3534d8 | |||
| 9759227d73 | |||
| f608872c11 | |||
| eff9544ef9 | |||
| 22fb464b87 | |||
| 3bace8c9e4 | |||
| 27fca28450 | |||
| afcce7a0ef | |||
| f33495dddc |
@@ -18,6 +18,7 @@
|
||||
|
||||
[options]
|
||||
emoji=true
|
||||
sharedmemory.heap_size=3221225472
|
||||
|
||||
module.system.node.resolve_dirname=node_modules
|
||||
module.system.node.resolve_dirname=app
|
||||
|
||||
Vendored
+2
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.validate.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.format.enable": false
|
||||
}
|
||||
@@ -87,7 +87,7 @@ docker run --rm outlinewiki/outline:latest yarn sequelize:migrate
|
||||
|
||||
If you're running Outline by cloning this repository, run the following command to upgrade:
|
||||
```
|
||||
yarn upgrade
|
||||
yarn run upgrade
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -20,8 +20,10 @@ const Authenticated = ({ children }: Props) => {
|
||||
// Watching for language changes here as this is the earliest point we have
|
||||
// the user available and means we can start loading translations faster
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
if (language && i18n.language !== language) {
|
||||
// Languages are stored in en_US format in the database, however the
|
||||
// frontend translation framework (i18next) expects en-US
|
||||
i18n.changeLanguage(language.replace("_", "-"));
|
||||
}
|
||||
}, [i18n, language]);
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import User from "models/User";
|
||||
import placeholder from "./placeholder.png";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
src: string,
|
||||
size: number,
|
||||
icon?: React.Node,
|
||||
};
|
||||
user?: User,
|
||||
onClick?: () => void,
|
||||
className?: string,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Avatar extends React.Component<Props> {
|
||||
|
||||
@@ -108,8 +108,8 @@ export const Inner = styled.span`
|
||||
${(props) => props.hasIcon && !props.hasText && "padding: 0 4px;"};
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "button" | "submit",
|
||||
value?: string,
|
||||
icon?: React.Node,
|
||||
iconColor?: string,
|
||||
@@ -118,9 +118,21 @@ export type Props = {
|
||||
innerRef?: React.ElementRef<any>,
|
||||
disclosure?: boolean,
|
||||
neutral?: boolean,
|
||||
danger?: boolean,
|
||||
primary?: boolean,
|
||||
disabled?: boolean,
|
||||
fullwidth?: boolean,
|
||||
autoFocus?: boolean,
|
||||
style?: Object,
|
||||
as?: React.ComponentType<any>,
|
||||
to?: string,
|
||||
onClick?: (event: SyntheticEvent<>) => mixed,
|
||||
borderOnHover?: boolean,
|
||||
};
|
||||
|
||||
"data-on"?: string,
|
||||
"data-event-category"?: string,
|
||||
"data-event-action"?: string,
|
||||
|};
|
||||
|
||||
function Button({
|
||||
type = "text",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick: (ev: SyntheticEvent<>) => void,
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function ButtonLink(props: Props) {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
|
||||
const Button = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.link};
|
||||
line-height: inherit;
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
`;
|
||||
@@ -1,18 +1,21 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import HelpText from "components/HelpText";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
export type Props = {
|
||||
export type Props = {|
|
||||
checked?: boolean,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
className?: string,
|
||||
name?: string,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
note?: string,
|
||||
short?: boolean,
|
||||
small?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const LabelText = styled.span`
|
||||
font-weight: 500;
|
||||
|
||||
@@ -4,13 +4,16 @@ import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
onClick?: (SyntheticEvent<>) => void | Promise<void>,
|
||||
children?: React.Node,
|
||||
selected?: boolean,
|
||||
disabled?: boolean,
|
||||
to?: string,
|
||||
href?: string,
|
||||
target?: "_blank",
|
||||
as?: string | React.ComponentType<*>,
|
||||
};
|
||||
|};
|
||||
|
||||
const MenuItem = ({
|
||||
onClick,
|
||||
|
||||
@@ -156,7 +156,7 @@ const Wrapper = styled(Flex)`
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: none;
|
||||
@@ -165,7 +165,7 @@ const Wrapper = styled(Flex)`
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
min-width: ${(props) => props.theme.sidebarWidth}px;
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ import * as React from "react";
|
||||
import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
limit?: number,
|
||||
};
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
export default function DocumentList({ limit, documents, ...rest }: Props) {
|
||||
const items = limit ? documents.splice(0, limit) : documents;
|
||||
|
||||
@@ -23,6 +23,7 @@ type Props = {|
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
@@ -44,6 +45,7 @@ function DocumentListItem(props: Props) {
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const {
|
||||
document,
|
||||
showNestedDocuments,
|
||||
showCollection,
|
||||
showPublished,
|
||||
showPin,
|
||||
@@ -104,6 +106,7 @@ function DocumentListItem(props: Props) {
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showNestedDocuments={showNestedDocuments}
|
||||
showLastViewed
|
||||
/>
|
||||
</Content>
|
||||
|
||||
@@ -23,19 +23,21 @@ const Modified = styled.span`
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
showNestedDocuments?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function DocumentMeta({
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
showNestedDocuments,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
@@ -123,6 +125,10 @@ function DocumentMeta({
|
||||
);
|
||||
};
|
||||
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? t("You") : updatedBy.name}
|
||||
@@ -135,6 +141,12 @@ function DocumentMeta({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{showNestedDocuments && nestedDocumentsCount > 0 && (
|
||||
<span>
|
||||
· {nestedDocumentsCount}{" "}
|
||||
{t("nested document", { count: nestedDocumentsCount })}
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
@@ -8,7 +8,7 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import { isMetaKey } from "utils/keyboard";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
import { isInternalUrl } from "utils/urls";
|
||||
|
||||
@@ -16,14 +16,31 @@ const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
export type Props = {|
|
||||
id?: string,
|
||||
value?: string,
|
||||
defaultValue?: string,
|
||||
readOnly?: boolean,
|
||||
grow?: boolean,
|
||||
disableEmbeds?: boolean,
|
||||
ui?: UiStore,
|
||||
};
|
||||
autoFocus?: boolean,
|
||||
template?: boolean,
|
||||
placeholder?: string,
|
||||
scrollTo?: string,
|
||||
readOnlyWriteCheckboxes?: boolean,
|
||||
onBlur?: (event: SyntheticEvent<>) => any,
|
||||
onFocus?: (event: SyntheticEvent<>) => any,
|
||||
onPublish?: (event: SyntheticEvent<>) => any,
|
||||
onSave?: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
onCancel?: () => any,
|
||||
onChange?: (getValue: () => string) => any,
|
||||
onSearchLink?: (title: string) => any,
|
||||
onHoverLink?: (event: MouseEvent) => any,
|
||||
onCreateLink?: (title: string) => Promise<string>,
|
||||
onImageUploadStart?: () => any,
|
||||
onImageUploadStop?: () => any,
|
||||
|};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
@@ -50,7 +67,7 @@ function Editor(props: PropsWithRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href) && !isMetaKey(event) && !event.shiftKey) {
|
||||
if (isInternalUrl(href) && !isModKey(event) && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
@@ -36,8 +37,8 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
const isReported = !!env.SENTRY_DSN && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -145,8 +145,8 @@ function IconPicker({ onOpen, icon, color, onChange }: Props) {
|
||||
</Label>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<Button {...props}>
|
||||
<Component role="button" color={color} size={30} />
|
||||
<Button aria-label={t("Show menu")} {...props}>
|
||||
<Component color={color} size={30} />
|
||||
</Button>
|
||||
)}
|
||||
</MenuButton>
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import * as React from "react";
|
||||
import { cdnPath } from "utils/urls";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
alt: string,
|
||||
src: string,
|
||||
};
|
||||
title?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
|};
|
||||
|
||||
export default function Image({ src, alt, ...rest }: Props) {
|
||||
return <img src={cdnPath(src)} alt={alt} {...rest} />;
|
||||
|
||||
+13
-4
@@ -2,9 +2,9 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
|
||||
const RealTextarea = styled.textarea`
|
||||
border: 0;
|
||||
@@ -75,8 +75,8 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = {
|
||||
type?: string,
|
||||
export type Props = {|
|
||||
type?: "text" | "email" | "checkbox" | "search",
|
||||
value?: string,
|
||||
label?: string,
|
||||
className?: string,
|
||||
@@ -85,9 +85,18 @@ export type Props = {
|
||||
short?: boolean,
|
||||
margin?: string | number,
|
||||
icon?: React.Node,
|
||||
name?: string,
|
||||
minLength?: number,
|
||||
maxLength?: number,
|
||||
autoFocus?: boolean,
|
||||
autoComplete?: boolean | string,
|
||||
readOnly?: boolean,
|
||||
required?: boolean,
|
||||
placeholder?: string,
|
||||
onChange?: (ev: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
onFocus?: (ev: SyntheticEvent<>) => void,
|
||||
onBlur?: (ev: SyntheticEvent<>) => void,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Input extends React.Component<Props> {
|
||||
|
||||
@@ -8,13 +8,13 @@ import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: string,
|
||||
minHeight?: number,
|
||||
maxHeight?: number,
|
||||
readOnly?: boolean,
|
||||
ui: UiStore,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class InputRich extends React.Component<Props> {
|
||||
|
||||
@@ -17,6 +17,8 @@ type Props = {
|
||||
theme: Theme,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
label?: string,
|
||||
labelHidden?: boolean,
|
||||
collectionId?: string,
|
||||
t: TFunction,
|
||||
};
|
||||
@@ -68,6 +70,8 @@ class InputSearch extends React.Component<Props> {
|
||||
color={this.focused ? theme.inputBorderFocused : theme.inputBorder}
|
||||
/>
|
||||
}
|
||||
label={this.props.label}
|
||||
labelHidden={this.props.labelHidden}
|
||||
onFocus={this.handleFocus}
|
||||
onBlur={this.handleBlur}
|
||||
margin={0}
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import { Outline, LabelText } from "./Input";
|
||||
|
||||
const Select = styled.select`
|
||||
border: 0;
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
padding: 4px 0;
|
||||
margin: 0 12px;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
height: 30px;
|
||||
|
||||
&:disabled,
|
||||
&::placeholder {
|
||||
@@ -35,6 +36,8 @@ export type Props = {
|
||||
className?: string,
|
||||
labelHidden?: boolean,
|
||||
options: Option[],
|
||||
onBlur?: () => void,
|
||||
onFocus?: () => void,
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -4,10 +4,10 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
label: React.Node | string,
|
||||
children: React.Node,
|
||||
};
|
||||
|};
|
||||
|
||||
const Labeled = ({ label, children, ...props }: Props) => (
|
||||
<Flex column {...props}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { languages, languageOptions } from "shared/i18n";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Flex from "components/Flex";
|
||||
import NoticeTip from "components/NoticeTip";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
@@ -68,7 +69,7 @@ export default function LanguagePrompt() {
|
||||
like to change?
|
||||
</Trans>
|
||||
<br />
|
||||
<a
|
||||
<Link
|
||||
onClick={() => {
|
||||
auth.updateUser({
|
||||
language,
|
||||
@@ -77,14 +78,24 @@ export default function LanguagePrompt() {
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</a>{" "}
|
||||
· <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</NoticeTip>
|
||||
);
|
||||
}
|
||||
|
||||
const Link = styled(ButtonLink)`
|
||||
color: ${(props) => props.theme.almostBlack};
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`;
|
||||
|
||||
const LanguageIcon = styled(Icon)`
|
||||
margin-right: 12px;
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
@@ -14,13 +15,15 @@ import UiStore from "stores/UiStore";
|
||||
import ErrorSuspended from "scenes/ErrorSuspended";
|
||||
import KeyboardShortcuts from "scenes/KeyboardShortcuts";
|
||||
import Analytics from "components/Analytics";
|
||||
import Button from "components/Button";
|
||||
import DocumentHistory from "components/DocumentHistory";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
import { LoadingIndicatorBar } from "components/LoadingIndicator";
|
||||
import Modal from "components/Modal";
|
||||
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 {
|
||||
@@ -99,6 +102,7 @@ class Layout extends React.Component<Props> {
|
||||
const { auth, t, ui } = this.props;
|
||||
const { user, team } = auth;
|
||||
const showSidebar = auth.authenticated && user && team;
|
||||
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
if (auth.isSuspended) return <ErrorSuspended />;
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
@@ -112,11 +116,19 @@ class Layout extends React.Component<Props> {
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
</Helmet>
|
||||
<SkipNavLink />
|
||||
<Analytics />
|
||||
|
||||
{this.props.ui.progressBarVisible && <LoadingIndicatorBar />}
|
||||
{this.props.notifications}
|
||||
|
||||
<MobileMenuButton
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
icon={<MenuIcon />}
|
||||
iconColor="currentColor"
|
||||
neutral
|
||||
/>
|
||||
|
||||
<Container auto>
|
||||
{showSidebar && (
|
||||
<Switch>
|
||||
@@ -125,10 +137,17 @@ class Layout extends React.Component<Props> {
|
||||
</Switch>
|
||||
)}
|
||||
|
||||
<SkipNavContent />
|
||||
<Content
|
||||
auto
|
||||
justify="center"
|
||||
sidebarCollapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
$isResizing={ui.sidebarIsResizing}
|
||||
$sidebarCollapsed={sidebarCollapsed}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: { marginLeft: `${ui.sidebarWidth}px` }
|
||||
}
|
||||
>
|
||||
{this.props.children}
|
||||
</Content>
|
||||
@@ -160,19 +179,38 @@ const Container = styled(Flex)`
|
||||
min-height: 100%;
|
||||
`;
|
||||
|
||||
const MobileMenuButton = styled(Button)`
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Content = styled(Flex)`
|
||||
margin: 0;
|
||||
transition: margin-left 100ms ease-out;
|
||||
transition: ${(props) =>
|
||||
props.$isResizing ? "none" : `margin-left 100ms ease-out`};
|
||||
|
||||
@media print {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-left: 0 !important;
|
||||
`}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
margin-left: ${(props) =>
|
||||
props.sidebarCollapsed
|
||||
? props.theme.sidebarCollapsedWidth
|
||||
: props.theme.sidebarWidth};
|
||||
${(props) =>
|
||||
props.$sidebarCollapsed &&
|
||||
`margin-left: ${props.theme.sidebarCollapsedWidth}px;`}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ const locales = {
|
||||
de: require(`date-fns/locale/de`),
|
||||
es: require(`date-fns/locale/es`),
|
||||
fr: require(`date-fns/locale/fr`),
|
||||
it: require(`date-fns/locale/it`),
|
||||
ko: require(`date-fns/locale/ko`),
|
||||
pt: require(`date-fns/locale/pt`),
|
||||
zh: require(`date-fns/locale/zh_cn`),
|
||||
};
|
||||
|
||||
let callbacks = [];
|
||||
|
||||
@@ -5,10 +5,10 @@ import { randomInteger } from "shared/random";
|
||||
import { pulsate } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
header?: boolean,
|
||||
height?: number,
|
||||
};
|
||||
|};
|
||||
|
||||
class Mask extends React.Component<Props> {
|
||||
width: number;
|
||||
@@ -23,7 +23,7 @@ class Mask extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Redacted width={this.width} {...this.props} />;
|
||||
return <Redacted width={this.width} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ import Scrollable from "components/Scrollable";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
children?: React.Node,
|
||||
isOpen: boolean,
|
||||
title?: string,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|};
|
||||
|
||||
const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
|
||||
@@ -5,13 +5,19 @@ import Document from "models/Document";
|
||||
import DocumentListItem from "components/DocumentListItem";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
documents: Document[],
|
||||
fetch: (options: ?Object) => Promise<void>,
|
||||
options?: Object,
|
||||
heading?: React.Node,
|
||||
empty?: React.Node,
|
||||
};
|
||||
showNestedDocuments?: boolean,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showPin?: boolean,
|
||||
showDraft?: boolean,
|
||||
showTemplate?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class PaginatedDocumentList extends React.Component<Props> {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// @flow
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { Route } from "react-router-dom";
|
||||
import env from "env";
|
||||
|
||||
let Component = Route;
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
Component = Sentry.withSentryRouting(Route);
|
||||
}
|
||||
|
||||
export default Component;
|
||||
@@ -1,28 +1,52 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "hooks/useWindowSize";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
shadow?: boolean,
|
||||
};
|
||||
topShadow?: boolean,
|
||||
bottomShadow?: boolean,
|
||||
|};
|
||||
|
||||
@observer
|
||||
class Scrollable extends React.Component<Props> {
|
||||
@observable shadow: boolean = false;
|
||||
function Scrollable({ shadow, topShadow, bottomShadow, ...rest }: Props) {
|
||||
const ref = React.useRef<?HTMLDivElement>();
|
||||
const [topShadowVisible, setTopShadow] = React.useState(false);
|
||||
const [bottomShadowVisible, setBottomShadow] = React.useState(false);
|
||||
const { height } = useWindowSize();
|
||||
|
||||
handleScroll = (ev: SyntheticMouseEvent<HTMLDivElement>) => {
|
||||
this.shadow = !!(this.props.shadow && ev.currentTarget.scrollTop > 0);
|
||||
};
|
||||
const updateShadows = React.useCallback(() => {
|
||||
const c = ref.current;
|
||||
if (!c) return;
|
||||
|
||||
render() {
|
||||
const { shadow, ...rest } = this.props;
|
||||
const scrollTop = c.scrollTop;
|
||||
const tsv = !!((shadow || topShadow) && scrollTop > 0);
|
||||
if (tsv !== topShadowVisible) {
|
||||
setTopShadow(tsv);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper onScroll={this.handleScroll} shadow={this.shadow} {...rest} />
|
||||
);
|
||||
}
|
||||
const wrapperHeight = c.scrollHeight - c.clientHeight;
|
||||
const bsv = !!((shadow || bottomShadow) && wrapperHeight - scrollTop !== 0);
|
||||
|
||||
if (bsv !== bottomShadowVisible) {
|
||||
setBottomShadow(bsv);
|
||||
}
|
||||
}, [shadow, topShadow, bottomShadow, topShadowVisible, bottomShadowVisible]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateShadows();
|
||||
}, [height, updateShadows]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
ref={ref}
|
||||
onScroll={updateShadows}
|
||||
$topShadowVisible={topShadowVisible}
|
||||
$bottomShadowVisible={bottomShadowVisible}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
@@ -31,9 +55,20 @@ const Wrapper = styled.div`
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
box-shadow: ${(props) =>
|
||||
props.shadow ? "0 1px inset rgba(0,0,0,.1)" : "none"};
|
||||
transition: all 250ms ease-in-out;
|
||||
box-shadow: ${(props) => {
|
||||
if (props.$topShadowVisible && props.$bottomShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1), 0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$topShadowVisible) {
|
||||
return "0 1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
if (props.$bottomShadowVisible) {
|
||||
return "0 -1px inset rgba(0,0,0,.1)";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}};
|
||||
transition: all 100ms ease-in-out;
|
||||
`;
|
||||
|
||||
export default Scrollable;
|
||||
export default observer(Scrollable);
|
||||
|
||||
+160
-161
@@ -1,6 +1,5 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
HomeIcon,
|
||||
@@ -10,14 +9,11 @@ import {
|
||||
ShapesIcon,
|
||||
TrashIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import CollectionNew from "scenes/CollectionNew";
|
||||
import Invite from "scenes/Invite";
|
||||
import Flex from "components/Flex";
|
||||
@@ -29,176 +25,179 @@ import Collections from "./components/Collections";
|
||||
import HeaderBlock from "./components/HeaderBlock";
|
||||
import Section from "./components/Section";
|
||||
import SidebarLink from "./components/SidebarLink";
|
||||
import useStores from "hooks/useStores";
|
||||
import AccountMenu from "menus/AccountMenu";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
documents: DocumentsStore,
|
||||
policies: PoliciesStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function MainSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { policies, auth, documents } = useStores();
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [
|
||||
createCollectionModalOpen,
|
||||
setCreateCollectionModalOpen,
|
||||
] = React.useState(false);
|
||||
|
||||
@observer
|
||||
class MainSidebar extends React.Component<Props> {
|
||||
@observable inviteModalOpen = false;
|
||||
@observable createCollectionModalOpen = false;
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
|
||||
componentDidMount() {
|
||||
this.props.documents.fetchDrafts();
|
||||
this.props.documents.fetchTemplates();
|
||||
}
|
||||
const handleCreateCollectionModalOpen = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
setCreateCollectionModalOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
handleCreateCollectionModalOpen = (ev: SyntheticEvent<>) => {
|
||||
const handleCreateCollectionModalClose = React.useCallback(() => {
|
||||
setCreateCollectionModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleInviteModalOpen = React.useCallback((ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.createCollectionModalOpen = true;
|
||||
};
|
||||
setInviteModalOpen(true);
|
||||
}, []);
|
||||
|
||||
handleCreateCollectionModalClose = (ev: SyntheticEvent<>) => {
|
||||
this.createCollectionModalOpen = false;
|
||||
};
|
||||
const handleInviteModalClose = React.useCallback(() => {
|
||||
setInviteModalOpen(false);
|
||||
}, []);
|
||||
|
||||
handleInviteModalOpen = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
render() {
|
||||
const { auth, documents, policies, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
return (
|
||||
<Sidebar>
|
||||
<AccountMenu>
|
||||
{(props) => (
|
||||
<HeaderBlock
|
||||
{...props}
|
||||
subheading={user.name}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
showDisclosure
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
)}
|
||||
</AccountMenu>
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={documents.active ? documents.active.template : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections onCreateCollection={handleCreateCollectionModalOpen} />
|
||||
</Section>
|
||||
</Scrollable>
|
||||
<Secondary>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={documents.active ? documents.active.isDeleted : undefined}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<SettingsIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Settings")}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/home"
|
||||
icon={<HomeIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
to="/settings/people"
|
||||
onClick={handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={`${t("Invite people")}…`}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={{
|
||||
pathname: "/search",
|
||||
state: { fromMenu: true },
|
||||
}}
|
||||
icon={<SearchIcon color="currentColor" />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/starred"
|
||||
icon={<StarredIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Starred")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/templates"
|
||||
icon={<ShapesIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Templates")}
|
||||
active={
|
||||
documents.active ? documents.active.template : undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/drafts"
|
||||
icon={<EditIcon color="currentColor" />}
|
||||
label={
|
||||
<Drafts align="center">
|
||||
{t("Drafts")}
|
||||
{documents.totalDrafts > 0 && (
|
||||
<Bubble count={documents.totalDrafts} />
|
||||
)}
|
||||
</Drafts>
|
||||
}
|
||||
active={
|
||||
documents.active
|
||||
? !documents.active.publishedAt &&
|
||||
!documents.active.isDeleted &&
|
||||
!documents.active.isTemplate
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections
|
||||
onCreateCollection={this.handleCreateCollectionModalOpen}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to="/archive"
|
||||
icon={<ArchiveIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Archive")}
|
||||
active={
|
||||
documents.active
|
||||
? documents.active.isArchived && !documents.active.isDeleted
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/trash"
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Trash")}
|
||||
active={
|
||||
documents.active ? documents.active.isDeleted : undefined
|
||||
}
|
||||
/>
|
||||
{can.invite && (
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
onClick={this.handleInviteModalOpen}
|
||||
icon={<PlusIcon color="currentColor" />}
|
||||
label={t("Invite people…")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={this.handleCreateCollectionModalClose}
|
||||
isOpen={this.createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={this.handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
</Secondary>
|
||||
</Flex>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={handleInviteModalClose}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={handleInviteModalClose} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Create a collection")}
|
||||
onRequestClose={handleCreateCollectionModalClose}
|
||||
isOpen={createCollectionModalOpen}
|
||||
>
|
||||
<CollectionNew onSubmit={handleCreateCollectionModalClose} />
|
||||
</Modal>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const Secondary = styled.div`
|
||||
overflow-x: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Drafts = styled(Flex)`
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<MainSidebar>(
|
||||
inject("documents", "policies", "auth")(MainSidebar)
|
||||
);
|
||||
export default observer(MainSidebar);
|
||||
|
||||
+112
-124
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
@@ -13,11 +13,9 @@ import {
|
||||
ExpandedIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import type { RouterHistory } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import Flex from "components/Flex";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
@@ -30,131 +28,123 @@ import Version from "./components/Version";
|
||||
import SlackIcon from "./icons/Slack";
|
||||
import ZapierIcon from "./icons/Zapier";
|
||||
import env from "env";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
policies: PoliciesStore,
|
||||
auth: AuthStore,
|
||||
t: TFunction,
|
||||
};
|
||||
function SettingsSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const team = useCurrentTeam();
|
||||
const { policies } = useStores();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
@observer
|
||||
class SettingsSidebar extends React.Component<Props> {
|
||||
returnToDashboard = () => {
|
||||
this.props.history.push("/home");
|
||||
};
|
||||
const returnToDashboard = React.useCallback(() => {
|
||||
history.push("/home");
|
||||
}, [history]);
|
||||
|
||||
render() {
|
||||
const { policies, t, auth } = this.props;
|
||||
const { team } = auth;
|
||||
if (!team) return null;
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={returnToDashboard}
|
||||
/>
|
||||
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<HeaderBlock
|
||||
subheading={
|
||||
<ReturnToApp align="center">
|
||||
<BackIcon color="currentColor" /> {t("Return to App")}
|
||||
</ReturnToApp>
|
||||
}
|
||||
teamName={team.name}
|
||||
logoUrl={team.avatarUrl}
|
||||
onClick={this.returnToDashboard}
|
||||
/>
|
||||
|
||||
<Flex auto column>
|
||||
<Scrollable shadow>
|
||||
<Section>
|
||||
<Header>Account</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>Team</Header>
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Flex auto column>
|
||||
<Scrollable topShadow>
|
||||
<Section>
|
||||
<Header>{t("Account")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings"
|
||||
icon={<ProfileIcon color="currentColor" />}
|
||||
label={t("Profile")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/notifications"
|
||||
icon={<EmailIcon color="currentColor" />}
|
||||
label={t("Notifications")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/tokens"
|
||||
icon={<CodeIcon color="currentColor" />}
|
||||
label={t("API Tokens")}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<Header>{t("Team")}</Header>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/details"
|
||||
icon={<TeamIcon color="currentColor" />}
|
||||
label={t("Details")}
|
||||
/>
|
||||
)}
|
||||
{can.update && (
|
||||
<SidebarLink
|
||||
to="/settings/security"
|
||||
icon={<PadlockIcon color="currentColor" />}
|
||||
label={t("Security")}
|
||||
/>
|
||||
)}
|
||||
<SidebarLink
|
||||
to="/settings/people"
|
||||
icon={<UserIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("People")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/groups"
|
||||
icon={<GroupIcon color="currentColor" />}
|
||||
exact={false}
|
||||
label={t("Groups")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to="/settings/shares"
|
||||
icon={<LinkIcon color="currentColor" />}
|
||||
label={t("Share Links")}
|
||||
/>
|
||||
{can.export && (
|
||||
<SidebarLink
|
||||
to="/settings/export"
|
||||
icon={<DocumentIcon color="currentColor" />}
|
||||
label={t("Export Data")}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
{can.update && (
|
||||
<Section>
|
||||
<Header>{t("Integrations")}</Header>
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/slack"
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
label="Slack"
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
{isHosted && (
|
||||
<SidebarLink
|
||||
to="/settings/integrations/zapier"
|
||||
icon={<ZapierIcon color="currentColor" />}
|
||||
label="Zapier"
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
{can.update && !isHosted && (
|
||||
<Section>
|
||||
<Header>{t("Installation")}</Header>
|
||||
<Version />
|
||||
</Section>
|
||||
)}
|
||||
</Scrollable>
|
||||
</Flex>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const BackIcon = styled(ExpandedIcon)`
|
||||
@@ -166,6 +156,4 @@ const ReturnToApp = styled(Flex)`
|
||||
height: 16px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<SettingsSidebar>(
|
||||
inject("auth", "policies")(SettingsSidebar)
|
||||
);
|
||||
export default observer(SettingsSidebar);
|
||||
|
||||
@@ -1,55 +1,171 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import CollapseToggle, { Button } from "./components/CollapseToggle";
|
||||
import CollapseToggle, {
|
||||
Button as CollapseButton,
|
||||
} from "./components/CollapseToggle";
|
||||
import ResizeBorder from "./components/ResizeBorder";
|
||||
import ResizeHandle from "./components/ResizeHandle";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
let BOUNCE_ANIMATION_MS = 250;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
};
|
||||
|
||||
const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
const [isResizing, setResizing] = React.useState(false);
|
||||
const isSmallerThanMinimum = width < minWidth;
|
||||
|
||||
const handleDrag = React.useCallback(
|
||||
(event: MouseEvent) => {
|
||||
// suppresses text selection
|
||||
event.preventDefault();
|
||||
|
||||
// this is simple because the sidebar is always against the left edge
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
setWidth(width);
|
||||
},
|
||||
[offset, maxWidth, setWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (isSmallerThanMinimum) {
|
||||
setWidth(minWidth);
|
||||
setAnimating(true);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
}, [isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
|
||||
const handleStartDrag = React.useCallback(
|
||||
(event) => {
|
||||
setOffset(event.pageX - width);
|
||||
setResizing(true);
|
||||
setAnimating(false);
|
||||
},
|
||||
[width]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAnimating) {
|
||||
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS);
|
||||
}
|
||||
}, [isAnimating]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
document.addEventListener("mousemove", handleDrag);
|
||||
document.addEventListener("mouseup", handleStopDrag);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleDrag);
|
||||
document.removeEventListener("mouseup", handleStopDrag);
|
||||
};
|
||||
}, [isResizing, handleDrag, handleStopDrag]);
|
||||
|
||||
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
const collapsed = ui.editMode || ui.sidebarCollapsed;
|
||||
|
||||
const {
|
||||
isAnimating,
|
||||
isSmallerThanMinimum,
|
||||
isResizing,
|
||||
handleStartDrag,
|
||||
} = useResize({
|
||||
width,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
setWidth: ui.setSidebarWidth,
|
||||
});
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ui.setSidebarResizing(isResizing);
|
||||
}, [ui, isResizing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location, previousLocation]);
|
||||
|
||||
const style = React.useMemo(
|
||||
() => ({
|
||||
width: `${width}px`,
|
||||
left:
|
||||
collapsed && !ui.mobileSidebarVisible
|
||||
? `${-width + theme.sidebarCollapsedWidth}px`
|
||||
: 0,
|
||||
}),
|
||||
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible]
|
||||
);
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
collapsed={ui.editMode || ui.sidebarCollapsed}
|
||||
style={style}
|
||||
$sidebarWidth={ui.sidebarWidth}
|
||||
$isAnimating={isAnimating}
|
||||
$isSmallerThanMinimum={isSmallerThanMinimum}
|
||||
$mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
$collapsed={collapsed}
|
||||
column
|
||||
>
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{!isResizing && (
|
||||
<CollapseToggle
|
||||
collapsed={ui.sidebarCollapsed}
|
||||
onClick={ui.toggleCollapsedSidebar}
|
||||
/>
|
||||
)}
|
||||
{ui.mobileSidebarVisible && (
|
||||
<Portal>
|
||||
<Fade>
|
||||
<Background onClick={ui.toggleMobileSidebar} />
|
||||
</Fade>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{children}
|
||||
{!ui.sidebarCollapsed && (
|
||||
<ResizeBorder
|
||||
onMouseDown={handleStartDrag}
|
||||
onDoubleClick={handleReset}
|
||||
$isResizing={isResizing}
|
||||
>
|
||||
<ResizeHandle aria-label={t("Resize sidebar")} />
|
||||
</ResizeBorder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -62,82 +178,67 @@ function Sidebar({ location, children }: Props) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const Background = styled.a`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
cursor: default;
|
||||
z-index: ${(props) => props.theme.depths.sidebar - 1};
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
const Container = styled(Flex)`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
transition: box-shadow, 100ms, ease-in-out, left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out,
|
||||
left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition}
|
||||
${(props) =>
|
||||
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""};
|
||||
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
max-width: 70%;
|
||||
min-width: 280px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
position: absolute;
|
||||
top: -50vh;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: auto;
|
||||
bottom: -50vh;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
left: ${(props) =>
|
||||
props.collapsed
|
||||
? `calc(-${props.theme.sidebarWidth} + ${props.theme.sidebarCollapsedWidth})`
|
||||
: 0};
|
||||
width: ${(props) => props.theme.sidebarWidth};
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
min-width: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
left: 0;
|
||||
left: 0 !important;
|
||||
box-shadow: ${(props) =>
|
||||
props.collapsed ? "rgba(0, 0, 0, 0.2) 1px 0 4px" : "none"};
|
||||
props.$collapsed
|
||||
? "rgba(0, 0, 0, 0.2) 1px 0 4px"
|
||||
: props.$isSmallerThanMinimum
|
||||
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
|
||||
: "none"};
|
||||
|
||||
& ${Button} {
|
||||
& ${CollapseButton} {
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
& ${Button}:hover {
|
||||
& ${CollapseButton}:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:hover):not(:focus-within) > div {
|
||||
opacity: ${(props) => (props.collapsed ? "0" : "1")};
|
||||
opacity: ${(props) => (props.$collapsed ? "0" : "1")};
|
||||
transition: opacity 100ms ease-in-out;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
const Toggle = styled.a`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: ${(props) => (props.mobileSidebarVisible ? "auto" : 0)};
|
||||
right: ${(props) => (props.mobileSidebarVisible ? 0 : "auto")};
|
||||
z-index: 1;
|
||||
margin: 12px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: none;
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(observer(Sidebar));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { meta } from "utils/keyboard";
|
||||
|
||||
type Props = {|
|
||||
collapsed: boolean,
|
||||
onClick?: () => void,
|
||||
onClick?: (event: SyntheticEvent<>) => void,
|
||||
|};
|
||||
|
||||
function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
@@ -21,7 +21,7 @@ function CollapseToggle({ collapsed, ...rest }: Props) {
|
||||
delay={500}
|
||||
placement="bottom"
|
||||
>
|
||||
<Button {...rest} aria-hidden>
|
||||
<Button {...rest} tabIndex="-1" aria-hidden>
|
||||
{collapsed ? (
|
||||
<NextIcon color="currentColor" />
|
||||
) : (
|
||||
@@ -43,7 +43,7 @@ export const Button = styled.button`
|
||||
z-index: 1;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
background: ${(props) => props.theme.sidebarItemBackground};
|
||||
background: transparent;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
|
||||
@@ -21,7 +21,6 @@ type Props = {|
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
index: number,
|
||||
@@ -33,7 +32,6 @@ function DocumentLink({
|
||||
canUpdate,
|
||||
collection,
|
||||
activeDocument,
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
index,
|
||||
@@ -213,7 +211,6 @@ function DocumentLink({
|
||||
<div ref={dropToReparent}>
|
||||
<DropToImport documentId={node.id} activeClassName="activeDropZone">
|
||||
<SidebarLink
|
||||
innerRef={isActiveDocument ? activeDocumentRef : undefined}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
to={{
|
||||
pathname: node.url,
|
||||
|
||||
@@ -5,18 +5,24 @@ import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
teamName: string,
|
||||
subheading: React.Node,
|
||||
showDisclosure?: boolean,
|
||||
onClick: (event: SyntheticEvent<>) => void,
|
||||
logoUrl: string,
|
||||
};
|
||||
|};
|
||||
|
||||
const HeaderBlock = React.forwardRef<Props, any>(
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => {
|
||||
return (
|
||||
({ showDisclosure, teamName, subheading, logoUrl, ...rest }: Props, ref) => (
|
||||
<Wrapper>
|
||||
<Header justify="flex-start" align="center" ref={ref} {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<TeamLogo
|
||||
alt={`${teamName} logo`}
|
||||
src={logoUrl}
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
@@ -25,8 +31,8 @@ const HeaderBlock = React.forwardRef<Props, any>(
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
</Wrapper>
|
||||
)
|
||||
);
|
||||
|
||||
const StyledExpandedIcon = styled(ExpandedIcon)`
|
||||
@@ -40,6 +46,7 @@ const Subheading = styled.div`
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: ${(props) => props.theme.sidebarText};
|
||||
`;
|
||||
|
||||
@@ -49,16 +56,20 @@ const TeamName = styled.div`
|
||||
padding-right: 24px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div`
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 20px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @flow
|
||||
// ref: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/NavLink.js
|
||||
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { createLocation } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
matchPath,
|
||||
type Location,
|
||||
} from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (to, currentLocation) =>
|
||||
typeof to === "function" ? to(currentLocation) : to;
|
||||
|
||||
const normalizeToLocation = (to, currentLocation) => {
|
||||
return typeof to === "string"
|
||||
? createLocation(to, null, null, currentLocation)
|
||||
: to;
|
||||
};
|
||||
|
||||
const joinClassnames = (...classnames) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
type Props = {|
|
||||
activeClassName?: String,
|
||||
activeStyle?: Object,
|
||||
className?: string,
|
||||
exact?: boolean,
|
||||
isActive?: any,
|
||||
location?: Location,
|
||||
strict?: boolean,
|
||||
style?: Object,
|
||||
to: string,
|
||||
|};
|
||||
|
||||
/**
|
||||
* A <Link> wrapper that knows if it's "active" or not.
|
||||
*/
|
||||
const NavLink = ({
|
||||
"aria-current": ariaCurrent = "page",
|
||||
activeClassName = "active",
|
||||
activeStyle,
|
||||
className: classNameProp,
|
||||
exact,
|
||||
isActive: isActiveProp,
|
||||
location: locationProp,
|
||||
strict,
|
||||
style: styleProp,
|
||||
to,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const linkRef = React.useRef();
|
||||
const context = React.useContext(RouterContext);
|
||||
const currentLocation = locationProp || context.location;
|
||||
const toLocation = normalizeToLocation(
|
||||
resolveToLocation(to, currentLocation),
|
||||
currentLocation
|
||||
);
|
||||
const { pathname: path } = toLocation;
|
||||
// Regex taken from: https://github.com/pillarjs/path-to-regexp/blob/master/index.js#L202
|
||||
const escapedPath = path && path.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
||||
|
||||
const match = escapedPath
|
||||
? matchPath(currentLocation.pathname, {
|
||||
path: escapedPath,
|
||||
exact,
|
||||
strict,
|
||||
})
|
||||
: null;
|
||||
const isActive = !!(isActiveProp
|
||||
? isActiveProp(match, currentLocation)
|
||||
: match);
|
||||
|
||||
const className = isActive
|
||||
? joinClassnames(classNameProp, activeClassName)
|
||||
: classNameProp;
|
||||
const style = isActive ? { ...styleProp, ...activeStyle } : styleProp;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isActive && linkRef.current) {
|
||||
scrollIntoView(linkRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "instant",
|
||||
});
|
||||
}
|
||||
}, [linkRef, isActive]);
|
||||
|
||||
const props = {
|
||||
"aria-current": (isActive && ariaCurrent) || null,
|
||||
className,
|
||||
style,
|
||||
to: toLocation,
|
||||
...rest,
|
||||
};
|
||||
|
||||
return <Link ref={linkRef} {...props} />;
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import ResizeHandle from "./ResizeHandle";
|
||||
|
||||
const ResizeBorder = styled.div`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: -6px;
|
||||
width: 12px;
|
||||
cursor: ew-resize;
|
||||
|
||||
${(props) =>
|
||||
props.$isResizing &&
|
||||
`
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
${ResizeHandle} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default ResizeBorder;
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
const ResizeHandle = styled.button`
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
transform: translateY(-50%);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 40px;
|
||||
right: -10px;
|
||||
width: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.sidebarBackground};
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
bottom: -24px;
|
||||
left: -12px;
|
||||
right: -12px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: ${(props) => props.theme.sidebarText};
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
pointer-events: all;
|
||||
cursor: ew-resize;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default ResizeHandle;
|
||||
@@ -5,7 +5,9 @@ import Flex from "components/Flex";
|
||||
const Section = styled(Flex)`
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin: 24px 8px;
|
||||
margin: 20px 8px;
|
||||
min-width: ${(props) => props.theme.sidebarMinWidth}px;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default Section;
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import {
|
||||
withRouter,
|
||||
NavLink,
|
||||
type RouterHistory,
|
||||
type Match,
|
||||
} from "react-router-dom";
|
||||
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import EventBoundary from "components/EventBoundary";
|
||||
import NavLink from "./NavLink";
|
||||
import { type Theme } from "types";
|
||||
|
||||
type Props = {
|
||||
@@ -46,7 +43,6 @@ function SidebarLink({
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
innerRef,
|
||||
depth,
|
||||
history,
|
||||
match,
|
||||
@@ -65,14 +61,14 @@ function SidebarLink({
|
||||
...style,
|
||||
};
|
||||
|
||||
const activeFontWeightOnly = {
|
||||
const activeDropStyle = {
|
||||
fontWeight: 600,
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNavLink
|
||||
<Link
|
||||
$isActiveDrop={isActiveDrop}
|
||||
activeStyle={isActiveDrop ? activeFontWeightOnly : activeStyle}
|
||||
activeStyle={isActiveDrop ? activeDropStyle : activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@@ -80,13 +76,12 @@ function SidebarLink({
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
ref={innerRef}
|
||||
className={className}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{menu && <Actions showActions={showActions}>{menu}</Actions>}
|
||||
</StyledNavLink>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,6 +91,7 @@ const IconWrapper = styled.span`
|
||||
margin-right: 4px;
|
||||
height: 24px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
const Actions = styled(EventBoundary)`
|
||||
@@ -119,11 +115,11 @@ const Actions = styled(EventBoundary)`
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
const Link = styled(NavLink)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-overflow: ellipsis;
|
||||
padding: 4px 16px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
transition: background 50ms, color 50ms;
|
||||
background: ${(props) =>
|
||||
@@ -136,7 +132,7 @@ const StyledNavLink = styled(NavLink)`
|
||||
|
||||
svg {
|
||||
${(props) => (props.$isActiveDrop ? `fill: ${props.theme.white};` : "")}
|
||||
transition: fill 50ms
|
||||
transition: fill 50ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -159,6 +155,10 @@ const StyledNavLink = styled(NavLink)`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 4px 16px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Label = styled.div`
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export const id = "skip-nav";
|
||||
|
||||
export default function SkipNavContent() {
|
||||
return <div id={id} />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { id } from "components/SkipNavContent";
|
||||
|
||||
export default function SkipNavLink() {
|
||||
return <Anchor href={`#${id}`}>Skip navigation</Anchor>;
|
||||
}
|
||||
|
||||
const Anchor = styled.a`
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&:focus {
|
||||
padding: 1rem;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
width: auto;
|
||||
height: auto;
|
||||
clip: auto;
|
||||
}
|
||||
`;
|
||||
+14
-7
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { StarredIcon } from "outline-icons";
|
||||
import { StarredIcon, UnstarredIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import NudeButton from "./NudeButton";
|
||||
@@ -11,6 +12,7 @@ type Props = {|
|
||||
|};
|
||||
|
||||
function Star({ size, document, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
@@ -30,12 +32,17 @@ function Star({ size, document, ...rest }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} size={size} {...rest}>
|
||||
<AnimatedStar
|
||||
solid={document.isStarred}
|
||||
size={size}
|
||||
color="currentColor"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
aria-label={document.isStarred ? t("Unstar") : t("Star")}
|
||||
{...rest}
|
||||
>
|
||||
{document.isStarred ? (
|
||||
<AnimatedStar size={size} color="currentColor" />
|
||||
) : (
|
||||
<AnimatedStar size={size} color="currentColor" as={UnstarredIcon} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { LabelText } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
width?: number,
|
||||
height?: number,
|
||||
label?: string,
|
||||
checked?: boolean,
|
||||
disabled?: boolean,
|
||||
onChange: (event: SyntheticInputEvent<HTMLInputElement>) => mixed,
|
||||
id?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
function Switch({ width = 38, height = 20, label, ...props }: Props) {
|
||||
const component = (
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const TeamLogo = styled.img`
|
||||
width: ${(props) => props.size || "auto"};
|
||||
height: ${(props) => props.size || "38px"};
|
||||
width: ${(props) =>
|
||||
props.width ? `${props.width}px` : props.size || "auto"};
|
||||
height: ${(props) =>
|
||||
props.height ? `${props.height}px` : props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
@@ -3,14 +3,14 @@ import Tippy from "@tippy.js/react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
tooltip: React.Node,
|
||||
shortcut?: React.Node,
|
||||
placement?: "top" | "bottom" | "left" | "right",
|
||||
children: React.Node,
|
||||
delay?: number,
|
||||
className?: string,
|
||||
};
|
||||
|};
|
||||
|
||||
class Tooltip extends React.Component<Props> {
|
||||
render() {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// @flow
|
||||
import styled from "styled-components";
|
||||
|
||||
const VisuallyHidden = styled("span")`
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(1px 1px 1px 1px); /* IE6, IE7 */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
`;
|
||||
|
||||
export default VisuallyHidden;
|
||||
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Image from "components/Image";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class GoogleDrawings extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<Image
|
||||
src="/images/google-drawings.png"
|
||||
alt="Google Drawings"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
|
||||
title="Google Drawings"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
|
||||
describe("GoogleDrawings", () => {
|
||||
const match = GoogleDrawings.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://docs.google.com/drawings".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d+)(?:|\/\?)/;
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
|
||||
@@ -6,6 +6,17 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
// https://www.styled-components.com/docs/basics#passed-props
|
||||
const Iframe = (props) => <iframe title="Embed" {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
src?: string,
|
||||
border?: boolean,
|
||||
@@ -129,17 +140,6 @@ const Bar = styled(Flex)`
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
// https://www.styled-components.com/docs/basics#passed-props
|
||||
const Iframe = (props) => <iframe {...props} />;
|
||||
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
|
||||
<Frame {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
@@ -10,6 +10,7 @@ import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
import Gist from "./Gist";
|
||||
import GoogleDocs from "./GoogleDocs";
|
||||
import GoogleDrawings from "./GoogleDrawings";
|
||||
import GoogleDrive from "./GoogleDrive";
|
||||
import GoogleSheets from "./GoogleSheets";
|
||||
import GoogleSlides from "./GoogleSlides";
|
||||
@@ -95,6 +96,13 @@ export default [
|
||||
component: Gist,
|
||||
matcher: matcher(Gist),
|
||||
},
|
||||
{
|
||||
title: "Google Drawings",
|
||||
keywords: "drawings",
|
||||
icon: () => <Img src="/images/google-drawings.png" />,
|
||||
component: GoogleDrawings,
|
||||
matcher: matcher(GoogleDrawings),
|
||||
},
|
||||
{
|
||||
title: "Google Drive",
|
||||
keywords: "drive",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useCurrentTeam() {
|
||||
const { auth } = useStores();
|
||||
invariant(auth.team, "team required");
|
||||
return auth.team;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import useStores from "./useStores";
|
||||
export default function useUserLocale() {
|
||||
const { auth } = useStores();
|
||||
|
||||
if (!auth.user) {
|
||||
if (!auth.user || !auth.user.language) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import * as React from "react";
|
||||
|
||||
export default function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = React.useState({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
const handleResize = debounce(() => {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}, 100);
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
+9
-2
@@ -1,11 +1,12 @@
|
||||
// @flow
|
||||
import "focus-visible";
|
||||
import { createBrowserHistory } from "history";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { render } from "react-dom";
|
||||
import { BrowserRouter as Router } from "react-router-dom";
|
||||
import { Router } from "react-router-dom";
|
||||
import { initI18n } from "shared/i18n";
|
||||
import stores from "stores";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
@@ -14,10 +15,16 @@ import Theme from "components/Theme";
|
||||
import Toasts from "components/Toasts";
|
||||
import Routes from "./routes";
|
||||
import env from "env";
|
||||
import { initSentry } from "utils/sentry";
|
||||
|
||||
initI18n();
|
||||
|
||||
const element = document.getElementById("root");
|
||||
const history = createBrowserHistory();
|
||||
|
||||
if (env.SENTRY_DSN) {
|
||||
initSentry(history);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
@@ -25,7 +32,7 @@ if (element) {
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Router>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function BreadcrumbMenu({ path }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show path to document")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Path to document")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -18,7 +18,7 @@ function CollectionGroupMemberMenu({ onMembers, onRemove }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import Collection from "models/Collection";
|
||||
import CollectionDelete from "scenes/CollectionDelete";
|
||||
import CollectionEdit from "scenes/CollectionEdit";
|
||||
@@ -13,7 +14,6 @@ import ContextMenu from "components/ContextMenu";
|
||||
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "components/ContextMenu/Template";
|
||||
import Modal from "components/Modal";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import useStores from "hooks/useStores";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
@@ -116,7 +116,7 @@ function CollectionMenu({
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
|
||||
@@ -38,7 +38,7 @@ function CollectionSortMenu({ collection, onOpen, onClose, ...rest }: Props) {
|
||||
<>
|
||||
<MenuButton {...menu}>
|
||||
{(props) => (
|
||||
<NudeButton {...props}>
|
||||
<NudeButton aria-label={t("Show sort menu")} {...props}>
|
||||
{alphabeticalSort ? <AlphabeticalSortIcon /> : <ManualSortIcon />}
|
||||
</NudeButton>
|
||||
)}
|
||||
|
||||
@@ -106,6 +106,7 @@ function DocumentMenu({
|
||||
|
||||
const handleStar = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.star();
|
||||
},
|
||||
@@ -114,6 +115,7 @@ function DocumentMenu({
|
||||
|
||||
const handleUnstar = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.unstar();
|
||||
},
|
||||
@@ -138,7 +140,11 @@ function DocumentMenu({
|
||||
{label ? (
|
||||
<MenuButton {...menu}>{label}</MenuButton>
|
||||
) : (
|
||||
<OverflowMenuButton className={className} {...menu} />
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
|
||||
@@ -17,7 +17,7 @@ function GroupMemberMenu({ onRemove }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -41,7 +41,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
>
|
||||
<GroupDelete group={group} onSubmit={() => setDeleteModalOpen(false)} />
|
||||
</Modal>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Group options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -17,7 +17,7 @@ function MemberMenu({ onRemove }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Member options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -31,9 +31,11 @@ function NewChildDocumentMenu({ document, label }: Props) {
|
||||
{
|
||||
title: (
|
||||
<span>
|
||||
<Trans>
|
||||
New document in <strong>{{ collectionName }}</strong>
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="New document in <em>{{ collectionName }}</em>"
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
to: newDocumentUrl(document.collectionId),
|
||||
|
||||
@@ -27,7 +27,6 @@ function NewDocumentMenu() {
|
||||
as={Link}
|
||||
to={newDocumentUrl(collections.orderedData[0].id)}
|
||||
icon={<PlusIcon />}
|
||||
small
|
||||
>
|
||||
{t("New doc")}
|
||||
</Button>
|
||||
|
||||
@@ -51,6 +51,7 @@ function RevisionMenu({ document, revision, className, iconColor }: Props) {
|
||||
<OverflowMenuButton
|
||||
className={className}
|
||||
iconColor={iconColor}
|
||||
aria-label={t("Show menu")}
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Revision options")}>
|
||||
|
||||
@@ -49,7 +49,7 @@ function ShareMenu({ share }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("Share options")}>
|
||||
<CopyToClipboard text={share.url} onCopy={handleCopy}>
|
||||
<MenuItem {...menu}>{t("Copy link")}</MenuItem>
|
||||
|
||||
@@ -88,7 +88,7 @@ function UserMenu({ user }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton {...menu} />
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu} aria-label={t("User options")}>
|
||||
<Template
|
||||
{...menu}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
@action
|
||||
updateFromJson = (data) => {
|
||||
updateFromJson = (data: Object) => {
|
||||
set(this, data);
|
||||
};
|
||||
|
||||
@@ -150,7 +150,7 @@ export default class Document extends BaseModel {
|
||||
return this.store.archive(this);
|
||||
};
|
||||
|
||||
restore = (options) => {
|
||||
restore = (options: { revisionId?: string, collectionId?: string }) => {
|
||||
return this.store.restore(this, options);
|
||||
};
|
||||
|
||||
@@ -233,7 +233,7 @@ export default class Document extends BaseModel {
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options: SaveOptions) => {
|
||||
save = async (options: SaveOptions = {}) => {
|
||||
if (this.isSaving) return this;
|
||||
|
||||
const isCreating = !this.id;
|
||||
@@ -246,7 +246,9 @@ export default class Document extends BaseModel {
|
||||
collectionId: this.collectionId,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
...options,
|
||||
publish: options.publish,
|
||||
done: options.done,
|
||||
autosave: options.autosave,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -257,7 +259,9 @@ export default class Document extends BaseModel {
|
||||
text: this.text,
|
||||
templateId: this.templateId,
|
||||
lastRevision: options.lastRevision,
|
||||
...options,
|
||||
publish: options.publish,
|
||||
done: options.done,
|
||||
autosave: options.autosave,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect, type Match } from "react-router-dom";
|
||||
import { Switch, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import Dashboard from "scenes/Dashboard";
|
||||
@@ -16,6 +16,7 @@ import Trash from "scenes/Trash";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Layout from "components/Layout";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Route from "components/ProfiledRoute";
|
||||
import SocketProvider from "components/SocketProvider";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { Switch } from "react-router-dom";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import FullscreenLoading from "components/FullscreenLoading";
|
||||
import Route from "components/ProfiledRoute";
|
||||
|
||||
const Authenticated = React.lazy(() => import("components/Authenticated"));
|
||||
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import { Switch } from "react-router-dom";
|
||||
import Settings from "scenes/Settings";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Export from "scenes/Settings/Export";
|
||||
@@ -12,6 +12,7 @@ import Shares from "scenes/Settings/Shares";
|
||||
import Slack from "scenes/Settings/Slack";
|
||||
import Tokens from "scenes/Settings/Tokens";
|
||||
import Zapier from "scenes/Settings/Zapier";
|
||||
import Route from "components/ProfiledRoute";
|
||||
|
||||
export default function SettingsRoutes() {
|
||||
return (
|
||||
|
||||
@@ -145,6 +145,8 @@ class CollectionScene extends React.Component<Props> {
|
||||
<InputSearch
|
||||
source="collection"
|
||||
placeholder={`${t("Search in collection")}…`}
|
||||
label={`${t("Search in collection")}…`}
|
||||
labelHidden
|
||||
collectionId={match.params.id}
|
||||
/>
|
||||
</Action>
|
||||
@@ -205,10 +207,12 @@ class CollectionScene extends React.Component<Props> {
|
||||
{collection.isEmpty ? (
|
||||
<Centered column>
|
||||
<HelpText>
|
||||
<Trans>
|
||||
<strong>{{ collectionName }}</strong> doesn’t contain any
|
||||
documents yet.
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="<em>{{ collectionName }}</em> doesn’t contain any
|
||||
documents yet."
|
||||
values={{ collectionName }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
<br />
|
||||
<Trans>Get started by creating a new one!</Trans>
|
||||
</HelpText>
|
||||
@@ -276,9 +280,12 @@ class CollectionScene extends React.Component<Props> {
|
||||
|
||||
<Tabs>
|
||||
<Tab to={collectionUrl(collection.id)} exact>
|
||||
{t("Documents")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "updated")} exact>
|
||||
{t("Recently updated")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "recent")} exact>
|
||||
<Tab to={collectionUrl(collection.id, "published")} exact>
|
||||
{t("Recently published")}
|
||||
</Tab>
|
||||
<Tab to={collectionUrl(collection.id, "old")} exact>
|
||||
@@ -312,8 +319,11 @@ class CollectionScene extends React.Component<Props> {
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "recent")}>
|
||||
<Redirect to={collectionUrl(collection.id, "published")} />
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id, "published")}>
|
||||
<PaginatedDocumentList
|
||||
key="recent"
|
||||
key="published"
|
||||
documents={documents.recentlyPublishedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
@@ -323,8 +333,9 @@ class CollectionScene extends React.Component<Props> {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)}>
|
||||
<Route path={collectionUrl(collection.id, "updated")}>
|
||||
<PaginatedDocumentList
|
||||
key="updated"
|
||||
documents={documents.recentlyUpdatedInCollection(
|
||||
collection.id
|
||||
)}
|
||||
@@ -333,6 +344,20 @@ class CollectionScene extends React.Component<Props> {
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
<Route path={collectionUrl(collection.id)} exact>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.rootInCollection(collection.id)}
|
||||
fetch={documents.fetchPage}
|
||||
options={{
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showNestedDocuments
|
||||
showPin
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import Collection from "models/Collection";
|
||||
import Group from "models/Group";
|
||||
import GroupNew from "scenes/GroupNew";
|
||||
import Button from "components/Button";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import GroupListItem from "components/GroupListItem";
|
||||
@@ -85,9 +86,9 @@ class AddGroupsToCollection extends React.Component<Props> {
|
||||
<Flex column>
|
||||
<HelpText>
|
||||
{t("Can’t find the group you’re looking for?")}{" "}
|
||||
<a role="button" onClick={this.handleNewGroupModalOpen}>
|
||||
<ButtonLink onClick={this.handleNewGroupModalOpen}>
|
||||
{t("Create a group")}
|
||||
</a>
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import UsersStore from "stores/UsersStore";
|
||||
import Collection from "models/Collection";
|
||||
import User from "models/User";
|
||||
import Invite from "scenes/Invite";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -81,9 +82,9 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
<Flex column>
|
||||
<HelpText>
|
||||
{t("Need to add someone who’s not yet on the team yet?")}{" "}
|
||||
<a role="button" onClick={this.handleInviteModalOpen}>
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
{t("Invite people to {{ teamName }}", { teamName: team.name })}
|
||||
</a>
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import UiStore from "stores/UiStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import Collection from "models/Collection";
|
||||
import Button from "components/Button";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -139,9 +140,9 @@ class CollectionMembers extends React.Component<Props> {
|
||||
documents in the private <strong>{collection.name}</strong>{" "}
|
||||
collection. You can make this collection visible to the entire
|
||||
team by{" "}
|
||||
<a role="button" onClick={this.props.onEdit}>
|
||||
<ButtonLink onClick={this.props.onEdit}>
|
||||
changing the visibility
|
||||
</a>
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
<span>
|
||||
@@ -160,9 +161,7 @@ class CollectionMembers extends React.Component<Props> {
|
||||
The <strong>{collection.name}</strong> collection is accessible by
|
||||
everyone on the team. If you want to limit who can view the
|
||||
collection,{" "}
|
||||
<a role="button" onClick={this.props.onEdit}>
|
||||
make it private
|
||||
</a>
|
||||
<ButtonLink onClick={this.props.onEdit}>make it private</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
@@ -64,7 +64,11 @@ function Dashboard() {
|
||||
</Switch>
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch source="dashboard" />
|
||||
<InputSearch
|
||||
source="dashboard"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -61,7 +61,7 @@ type Props = {
|
||||
document: Document,
|
||||
revision: Revision,
|
||||
readOnly: boolean,
|
||||
onCreateLink: (title: string) => string,
|
||||
onCreateLink: (title: string) => Promise<string>,
|
||||
onSearchLink: (term: string) => any,
|
||||
theme: Theme,
|
||||
auth: AuthStore,
|
||||
|
||||
@@ -9,24 +9,23 @@ import parseTitle from "shared/utils/parseTitle";
|
||||
import Document from "models/Document";
|
||||
import ClickablePadding from "components/ClickablePadding";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
import Editor, { type Props as EditorProps } from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import Star, { AnimatedStar } from "components/Star";
|
||||
import { isMetaKey } from "utils/keyboard";
|
||||
import { isModKey } from "utils/keyboard";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
...EditorProps,
|
||||
onChangeTitle: (event: SyntheticInputEvent<>) => void,
|
||||
title: string,
|
||||
defaultValue: string,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
isShare: boolean,
|
||||
readOnly?: boolean,
|
||||
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
|
||||
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
|
||||
innerRef: { current: any },
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@@ -55,7 +54,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (isMetaKey(event)) {
|
||||
if (isModKey(event)) {
|
||||
this.props.onSave({ done: true });
|
||||
return;
|
||||
}
|
||||
@@ -69,12 +68,12 @@ class DocumentEditor extends React.Component<Props> {
|
||||
this.focusAtStart();
|
||||
return;
|
||||
}
|
||||
if (event.key === "p" && isMetaKey(event) && event.shiftKey) {
|
||||
if (event.key === "p" && isModKey(event) && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({ publish: true, done: true });
|
||||
return;
|
||||
}
|
||||
if (event.key === "s" && isMetaKey(event)) {
|
||||
if (event.key === "s" && isModKey(event)) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({});
|
||||
return;
|
||||
@@ -98,6 +97,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
isShare,
|
||||
readOnly,
|
||||
innerRef,
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const { emoji } = parseTitle(title);
|
||||
@@ -135,12 +135,13 @@ class DocumentEditor extends React.Component<Props> {
|
||||
/>
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
autoFocus={title && !this.props.defaultValue}
|
||||
autoFocus={!!title && !this.props.defaultValue}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
readOnly={readOnly}
|
||||
grow
|
||||
{...this.props}
|
||||
{...rest}
|
||||
/>
|
||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||
{this.activeLinkEvent && !isShare && readOnly && (
|
||||
|
||||
@@ -171,7 +171,6 @@ class Header extends React.Component<Props> {
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -223,7 +222,6 @@ class Header extends React.Component<Props> {
|
||||
icon={isPubliclyShared ? <GlobeIcon /> : undefined}
|
||||
onClick={this.handleShareLink}
|
||||
neutral
|
||||
small
|
||||
>
|
||||
{t("Share")}
|
||||
</Button>
|
||||
@@ -242,9 +240,7 @@ class Header extends React.Component<Props> {
|
||||
<Button
|
||||
onClick={this.handleSave}
|
||||
disabled={savingIsDisabled}
|
||||
isSaving={isSaving}
|
||||
neutral={isDraft}
|
||||
small
|
||||
>
|
||||
{isDraft ? t("Save Draft") : t("Done Editing")}
|
||||
</Button>
|
||||
@@ -265,7 +261,6 @@ class Header extends React.Component<Props> {
|
||||
icon={<EditIcon />}
|
||||
to={editDocumentUrl(this.props.document)}
|
||||
neutral
|
||||
small
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
@@ -300,7 +295,6 @@ class Header extends React.Component<Props> {
|
||||
templateId: document.id,
|
||||
})}
|
||||
primary
|
||||
small
|
||||
>
|
||||
{t("New from template")}
|
||||
</Button>
|
||||
@@ -316,9 +310,7 @@ class Header extends React.Component<Props> {
|
||||
>
|
||||
<Button
|
||||
onClick={this.handlePublish}
|
||||
title={t("Publish document")}
|
||||
disabled={publishingIsDisabled}
|
||||
small
|
||||
>
|
||||
{isPublishing ? `${t("Publishing")}…` : t("Publish")}
|
||||
</Button>
|
||||
@@ -339,7 +331,6 @@ class Header extends React.Component<Props> {
|
||||
{...props}
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
)}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
|
||||
@@ -7,11 +7,11 @@ import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import type { NavigationNode } from "types";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
document: Document | NavigationNode,
|
||||
anchor?: string,
|
||||
showCollection?: boolean,
|
||||
};
|
||||
|};
|
||||
|
||||
const DocumentLink = styled(Link)`
|
||||
display: block;
|
||||
|
||||
@@ -32,15 +32,15 @@ class References extends React.Component<Props> {
|
||||
: [];
|
||||
|
||||
const showBacklinks = !!backlinks.length;
|
||||
const showChildren = !!children.length;
|
||||
const showNestedDocuments = !!children.length;
|
||||
const isBacklinksTab =
|
||||
this.props.location.hash === "#backlinks" || !showChildren;
|
||||
this.props.location.hash === "#backlinks" || !showNestedDocuments;
|
||||
|
||||
return (
|
||||
(showBacklinks || showChildren) && (
|
||||
(showBacklinks || showNestedDocuments) && (
|
||||
<Fade>
|
||||
<Tabs>
|
||||
{showChildren && (
|
||||
{showNestedDocuments && (
|
||||
<Tab to="#children" isActive={() => !isBacklinksTab}>
|
||||
Nested documents
|
||||
</Tab>
|
||||
|
||||
@@ -79,17 +79,17 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
<form onSubmit={handleSubmit}>
|
||||
<HelpText>
|
||||
{document.isTemplate ? (
|
||||
<Trans>
|
||||
Are you sure you want to delete the{" "}
|
||||
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
|
||||
template?
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="Are you sure you want to delete the <em>{{ documentTitle }}</em> template?"
|
||||
values={{ documentTitle: document.titleWithDefault }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{{ documentTitle: document.titleWithDefault }}</strong>{" "}
|
||||
document will delete all of its history and any nested documents.
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents."
|
||||
values={{ documentTitle: document.titleWithDefault }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
)}
|
||||
</HelpText>
|
||||
{canArchive && (
|
||||
|
||||
@@ -43,7 +43,7 @@ class DocumentShare extends React.Component<Props> {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
await share.save({ published: event.target.checked });
|
||||
await share.save({ published: event.currentTarget.checked });
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message, { type: "error" });
|
||||
} finally {
|
||||
@@ -115,7 +115,7 @@ class DocumentShare extends React.Component<Props> {
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
|
||||
<a href={share.url} target="_blank">
|
||||
<a href={share.url} target="_blank" rel="noreferrer">
|
||||
Preview
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,11 @@ class Drafts extends React.Component<Props> {
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch source="drafts" />
|
||||
<InputSearch
|
||||
source="drafts"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -21,14 +21,11 @@ const ErrorSuspended = ({ auth }: { auth: AuthStore }) => {
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
<Trans>
|
||||
A team admin (
|
||||
<strong>
|
||||
{{ suspendedContactEmail: auth.suspendedContactEmail }}
|
||||
</strong>
|
||||
) has suspended your account. To re-activate your account, please
|
||||
reach out to them directly.
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly."
|
||||
values={{ suspendedContactEmail: auth.suspendedContactEmail }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import UsersStore from "stores/UsersStore";
|
||||
import Group from "models/Group";
|
||||
import User from "models/User";
|
||||
import Invite from "scenes/Invite";
|
||||
import ButtonLink from "components/ButtonLink";
|
||||
import Empty from "components/Empty";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -81,12 +82,11 @@ class AddPeopleToGroup extends React.Component<Props> {
|
||||
{t(
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?"
|
||||
)}{" "}
|
||||
<a role="button" onClick={this.handleInviteModalOpen}>
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
{t("Invite them to {{teamName}}", { teamName: team.name })}
|
||||
</a>
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
|
||||
+118
-125
@@ -1,12 +1,11 @@
|
||||
// @flow
|
||||
import { find } from "lodash";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { BackIcon, EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Redirect, Link } from "react-router-dom";
|
||||
import { Redirect, Link, type Location } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import getQueryVariable from "shared/utils/getQueryVariable";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import ButtonLarge from "components/ButtonLarge";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
@@ -18,147 +17,141 @@ import TeamLogo from "components/TeamLogo";
|
||||
import Notices from "./Notices";
|
||||
import Service from "./Service";
|
||||
import env from "env";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
auth: AuthStore,
|
||||
location: Object,
|
||||
};
|
||||
type Props = {|
|
||||
location: Location,
|
||||
|};
|
||||
|
||||
type State = {
|
||||
emailLinkSentTo: string,
|
||||
};
|
||||
function Login({ location }: Props) {
|
||||
const { auth } = useStores();
|
||||
const { config } = auth;
|
||||
const [emailLinkSentTo, setEmailLinkSentTo] = React.useState("");
|
||||
const isCreate = location.pathname === "/create";
|
||||
|
||||
@observer
|
||||
class Login extends React.Component<Props, State> {
|
||||
state = {
|
||||
emailLinkSentTo: "",
|
||||
};
|
||||
const handleReset = React.useCallback(() => {
|
||||
setEmailLinkSentTo("");
|
||||
}, []);
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ emailLinkSentTo: "" });
|
||||
};
|
||||
const handleEmailSuccess = React.useCallback((email) => {
|
||||
setEmailLinkSentTo(email);
|
||||
}, []);
|
||||
|
||||
handleEmailSuccess = (email) => {
|
||||
this.setState({ emailLinkSentTo: email });
|
||||
};
|
||||
React.useEffect(() => {
|
||||
auth.fetchConfig();
|
||||
}, [auth]);
|
||||
|
||||
render() {
|
||||
const { auth, location } = this.props;
|
||||
const { config } = auth;
|
||||
const isCreate = location.pathname === "/create";
|
||||
console.log(config);
|
||||
|
||||
if (auth.authenticated) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
if (auth.authenticated) {
|
||||
return <Redirect to="/home" />;
|
||||
}
|
||||
|
||||
// we're counting on the config request being fast
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
// we're counting on the config request being fast
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleServices = config.services.length > 1;
|
||||
const defaultService = find(
|
||||
config.services,
|
||||
(service) => service.id === auth.lastSignedIn && !isCreate
|
||||
);
|
||||
const hasMultipleServices = config.services.length > 1;
|
||||
const defaultService = find(
|
||||
config.services,
|
||||
(service) => service.id === auth.lastSignedIn && !isCreate
|
||||
);
|
||||
|
||||
const header =
|
||||
env.DEPLOYMENT === "hosted" &&
|
||||
(config.hostname ? (
|
||||
<Back href={env.URL}>
|
||||
<BackIcon color="currentColor" /> Back to home
|
||||
</Back>
|
||||
) : (
|
||||
<Back href="https://www.getoutline.com">
|
||||
<BackIcon color="currentColor" /> Back to website
|
||||
</Back>
|
||||
));
|
||||
|
||||
if (this.state.emailLinkSentTo) {
|
||||
return (
|
||||
<Background>
|
||||
{header}
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<PageTitle title="Check your email" />
|
||||
<CheckEmailIcon size={38} color="currentColor" />
|
||||
|
||||
<Heading centered>Check your email</Heading>
|
||||
<Note>
|
||||
A magic sign-in link has been sent to the email{" "}
|
||||
<em>{this.state.emailLinkSentTo}</em>, no password needed.
|
||||
</Note>
|
||||
<br />
|
||||
<ButtonLarge onClick={this.handleReset} fullwidth neutral>
|
||||
Back to login
|
||||
</ButtonLarge>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
const header =
|
||||
env.DEPLOYMENT === "hosted" &&
|
||||
(config.hostname ? (
|
||||
<Back href={env.URL}>
|
||||
<BackIcon color="currentColor" /> Back to home
|
||||
</Back>
|
||||
) : (
|
||||
<Back href="https://www.getoutline.com">
|
||||
<BackIcon color="currentColor" /> Back to website
|
||||
</Back>
|
||||
));
|
||||
|
||||
if (emailLinkSentTo) {
|
||||
return (
|
||||
<Background>
|
||||
{header}
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<PageTitle title="Login" />
|
||||
<Logo>
|
||||
{env.TEAM_LOGO && env.DEPLOYMENT !== "hosted" ? (
|
||||
<TeamLogo src={env.TEAM_LOGO} />
|
||||
) : (
|
||||
<OutlineLogo size={38} fill="currentColor" />
|
||||
)}
|
||||
</Logo>
|
||||
<PageTitle title="Check your email" />
|
||||
<CheckEmailIcon size={38} color="currentColor" />
|
||||
|
||||
{isCreate ? (
|
||||
<Heading centered>Create an account</Heading>
|
||||
) : (
|
||||
<Heading centered>Login to {config.name || "Outline"}</Heading>
|
||||
)}
|
||||
|
||||
<Notices notice={getQueryVariable("notice")} />
|
||||
|
||||
{defaultService && (
|
||||
<React.Fragment key={defaultService.id}>
|
||||
<Service
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={this.handleEmailSuccess}
|
||||
{...defaultService}
|
||||
/>
|
||||
{hasMultipleServices && (
|
||||
<>
|
||||
<Note>
|
||||
You signed in with {defaultService.name} last time.
|
||||
</Note>
|
||||
<Or />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{config.services.map((service) => {
|
||||
if (defaultService && service.id === defaultService.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Service
|
||||
key={service.id}
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={this.handleEmailSuccess}
|
||||
{...service}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isCreate && (
|
||||
<Note>
|
||||
Already have an account? Go to <Link to="/">login</Link>.
|
||||
</Note>
|
||||
)}
|
||||
<Heading centered>Check your email</Heading>
|
||||
<Note>
|
||||
A magic sign-in link has been sent to the email{" "}
|
||||
<em>{emailLinkSentTo}</em>, no password needed.
|
||||
</Note>
|
||||
<br />
|
||||
<ButtonLarge onClick={handleReset} fullwidth neutral>
|
||||
Back to login
|
||||
</ButtonLarge>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Background>
|
||||
{header}
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<PageTitle title="Login" />
|
||||
<Logo>
|
||||
{env.TEAM_LOGO && env.DEPLOYMENT !== "hosted" ? (
|
||||
<TeamLogo src={env.TEAM_LOGO} />
|
||||
) : (
|
||||
<OutlineLogo size={38} fill="currentColor" />
|
||||
)}
|
||||
</Logo>
|
||||
|
||||
{isCreate ? (
|
||||
<Heading centered>Create an account</Heading>
|
||||
) : (
|
||||
<Heading centered>Login to {config.name || "Outline"}</Heading>
|
||||
)}
|
||||
|
||||
<Notices notice={getQueryVariable("notice")} />
|
||||
|
||||
{defaultService && (
|
||||
<React.Fragment key={defaultService.id}>
|
||||
<Service
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
{...defaultService}
|
||||
/>
|
||||
{hasMultipleServices && (
|
||||
<>
|
||||
<Note>You signed in with {defaultService.name} last time.</Note>
|
||||
<Or />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
{config.services.map((service) => {
|
||||
if (defaultService && service.id === defaultService.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Service
|
||||
key={service.id}
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
{...service}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{isCreate && (
|
||||
<Note>
|
||||
Already have an account? Go to <Link to="/">login</Link>.
|
||||
</Note>
|
||||
)}
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
const CheckEmailIcon = styled(EmailIcon)`
|
||||
@@ -234,4 +227,4 @@ const Centered = styled(Flex)`
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
export default inject("auth")(Login);
|
||||
export default observer(Login);
|
||||
|
||||
@@ -282,10 +282,11 @@ class Search extends React.Component<Props> {
|
||||
{showShortcutTip && (
|
||||
<Fade>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
Use the <strong>{{ meta: metaDisplay }}+K</strong> shortcut to
|
||||
search from anywhere in your knowledge base
|
||||
</Trans>
|
||||
<Trans
|
||||
defaults="Use the <em>{{ meta }}+K</em> shortcut to search from anywhere in your knowledge base"
|
||||
values={{ meta: metaDisplay }}
|
||||
components={{ em: <strong /> }}
|
||||
/>
|
||||
</HelpText>
|
||||
</Fade>
|
||||
)}
|
||||
|
||||
@@ -154,13 +154,16 @@ class Profile extends React.Component<Props> {
|
||||
</form>
|
||||
|
||||
<DangerZone>
|
||||
<LabelText>{t("Delete Account")}</LabelText>
|
||||
<p>
|
||||
{t(
|
||||
"You may delete your account at any time, note that this is unrecoverable"
|
||||
)}
|
||||
. <a onClick={this.toggleDeleteAccount}>{t("Delete account")}</a>.
|
||||
</p>
|
||||
<h2>{t("Delete Account")}</h2>
|
||||
<HelpText small>
|
||||
<Trans>
|
||||
You may delete your account at any time, note that this is
|
||||
unrecoverable
|
||||
</Trans>
|
||||
</HelpText>
|
||||
<Button onClick={this.toggleDeleteAccount} neutral>
|
||||
{t("Delete account")}…
|
||||
</Button>
|
||||
</DangerZone>
|
||||
{this.showDeleteModal && (
|
||||
<UserDelete onRequestClose={this.toggleDeleteAccount} />
|
||||
@@ -171,10 +174,7 @@ class Profile extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const DangerZone = styled.div`
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
margin-top: 60px;
|
||||
`;
|
||||
|
||||
const ProfilePicture = styled(Flex)`
|
||||
|
||||
@@ -17,10 +17,9 @@ function Zapier() {
|
||||
</HelpText>
|
||||
<p>
|
||||
<Button
|
||||
as="a"
|
||||
href="https://zapier.com/apps/outline"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
onClick={() =>
|
||||
(window.location.href = "https://zapier.com/apps/outline")
|
||||
}
|
||||
>
|
||||
Open Zapier →
|
||||
</Button>
|
||||
|
||||
@@ -48,7 +48,11 @@ function Starred(props: Props) {
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch source="starred" />
|
||||
<InputSearch
|
||||
source="starred"
|
||||
label={t("Search documents")}
|
||||
labelHidden
|
||||
/>
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -34,10 +34,10 @@ class UserDelete extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { auth, ...rest } = this.props;
|
||||
const { onRequestClose } = this.props;
|
||||
|
||||
return (
|
||||
<Modal isOpen title="Delete Account" {...rest}>
|
||||
<Modal isOpen title="Delete Account" onRequestClose={onRequestClose}>
|
||||
<Flex column>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
|
||||
@@ -19,11 +19,11 @@ import Subheading from "components/Subheading";
|
||||
import useCurrentUser from "hooks/useCurrentUser";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
user: User,
|
||||
history: RouterHistory,
|
||||
onRequestClose: () => void,
|
||||
};
|
||||
|};
|
||||
|
||||
function UserProfile(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
+10
-8
@@ -1,27 +1,30 @@
|
||||
// @flow
|
||||
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 RootStore from "stores/RootStore";
|
||||
import Policy from "models/Policy";
|
||||
import Team from "models/Team";
|
||||
import User from "models/User";
|
||||
import env from "env";
|
||||
import { client } from "utils/ApiClient";
|
||||
import { getCookieDomain } from "utils/domains";
|
||||
|
||||
const AUTH_STORE = "AUTH_STORE";
|
||||
const NO_REDIRECT_PATHS = ["/", "/create", "/home"];
|
||||
|
||||
type Service = {
|
||||
type Service = {|
|
||||
id: string,
|
||||
name: string,
|
||||
authUrl: string,
|
||||
};
|
||||
|};
|
||||
|
||||
type Config = {
|
||||
type Config = {|
|
||||
name?: string,
|
||||
hostname?: string,
|
||||
services: Service[],
|
||||
};
|
||||
|};
|
||||
|
||||
export default class AuthStore {
|
||||
@observable user: ?User;
|
||||
@@ -45,7 +48,6 @@ export default class AuthStore {
|
||||
// no-op Safari private mode
|
||||
}
|
||||
|
||||
setImmediate(() => this.fetchConfig());
|
||||
this.rehydrate(data);
|
||||
|
||||
// persists this entire store to localstorage whenever any keys are changed
|
||||
@@ -87,7 +89,7 @@ export default class AuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
addPolicies = (policies) => {
|
||||
addPolicies = (policies: Policy[]) => {
|
||||
if (policies) {
|
||||
policies.forEach((policy) => this.rootStore.policies.add(policy));
|
||||
}
|
||||
@@ -125,8 +127,8 @@ export default class AuthStore {
|
||||
this.user = new User(user);
|
||||
this.team = new Team(team);
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.configureScope(function (scope) {
|
||||
if (env.SENTRY_DSN) {
|
||||
Sentry.configureScope(function (scope) {
|
||||
scope.setUser({ id: user.id });
|
||||
scope.setExtra("team", team.name);
|
||||
scope.setExtra("teamId", team.id);
|
||||
|
||||
@@ -111,6 +111,15 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
);
|
||||
}
|
||||
|
||||
rootInCollection(collectionId: string): Document[] {
|
||||
const collection = this.rootStore.collections.get(collectionId);
|
||||
if (!collection) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return compact(collection.documents.map((node) => this.get(node.id)));
|
||||
}
|
||||
|
||||
leastRecentlyUpdatedInCollection(collectionId: string): Document[] {
|
||||
return orderBy(this.inCollection(collectionId), "updatedAt", "asc");
|
||||
}
|
||||
@@ -174,7 +183,13 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
return this.drafts().length;
|
||||
}
|
||||
|
||||
drafts = (options = {}): Document[] => {
|
||||
drafts = (
|
||||
options: {
|
||||
...PaginationParams,
|
||||
dateFilter?: "day" | "week" | "month" | "year",
|
||||
collectionId?: string,
|
||||
} = {}
|
||||
): Document[] => {
|
||||
let drafts = filter(
|
||||
orderBy(this.all, "updatedAt", "desc"),
|
||||
(doc) => !doc.publishedAt
|
||||
@@ -185,7 +200,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
drafts,
|
||||
(draft) =>
|
||||
new Date(draft.updatedAt) >=
|
||||
subtractDate(new Date(), options.dateFilter)
|
||||
subtractDate(new Date(), options.dateFilter || "year")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +260,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@action
|
||||
fetchNamedPage = async (
|
||||
request: string = "list",
|
||||
options: ?PaginationParams
|
||||
options: ?Object
|
||||
): Promise<?(Document[])> => {
|
||||
this.isFetching = true;
|
||||
|
||||
@@ -338,10 +353,9 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
searchTitles = async (query: string, options: PaginationParams = {}) => {
|
||||
searchTitles = async (query: string) => {
|
||||
const res = await client.get("/documents.search_titles", {
|
||||
query,
|
||||
...options,
|
||||
});
|
||||
invariant(res && res.data, "Search response should be available");
|
||||
|
||||
@@ -354,7 +368,15 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@action
|
||||
search = async (
|
||||
query: string,
|
||||
options: PaginationParams = {}
|
||||
options: {
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
dateFilter?: "day" | "week" | "month" | "year",
|
||||
includeArchived?: boolean,
|
||||
includeDrafts?: boolean,
|
||||
collectionId?: string,
|
||||
userId?: string,
|
||||
}
|
||||
): Promise<SearchResult[]> => {
|
||||
const compactedOptions = omitBy(options, (o) => !o);
|
||||
const res = await client.get("/documents.search", {
|
||||
@@ -601,10 +623,14 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
restore = async (document: Document, options = {}) => {
|
||||
restore = async (
|
||||
document: Document,
|
||||
options: { revisionId?: string, collectionId?: string } = {}
|
||||
) => {
|
||||
const res = await client.post("/documents.restore", {
|
||||
id: document.id,
|
||||
...options,
|
||||
revisionId: options.revisionId,
|
||||
collectionId: options.collectionId,
|
||||
});
|
||||
runInAction("Document#restore", () => {
|
||||
invariant(res && res.data, "Data should be available");
|
||||
|
||||
+27
-3
@@ -2,6 +2,7 @@
|
||||
import { orderBy } from "lodash";
|
||||
import { observable, action, autorun, computed } from "mobx";
|
||||
import { v4 } from "uuid";
|
||||
import { light as defaultTheme } from "shared/styles/theme";
|
||||
import Collection from "models/Collection";
|
||||
import Document from "models/Document";
|
||||
import type { Toast } from "types";
|
||||
@@ -23,7 +24,9 @@ class UiStore {
|
||||
@observable editMode: boolean = false;
|
||||
@observable tocVisible: boolean = false;
|
||||
@observable mobileSidebarVisible: boolean = false;
|
||||
@observable sidebarWidth: number;
|
||||
@observable sidebarCollapsed: boolean = false;
|
||||
@observable sidebarIsResizing: boolean = false;
|
||||
@observable toasts: Map<string, Toast> = new Map();
|
||||
lastToastId: string;
|
||||
|
||||
@@ -54,6 +57,7 @@ class UiStore {
|
||||
// persisted keys
|
||||
this.languagePromptDismissed = data.languagePromptDismissed;
|
||||
this.sidebarCollapsed = data.sidebarCollapsed;
|
||||
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
|
||||
this.tocVisible = data.tocVisible;
|
||||
this.theme = data.theme || "system";
|
||||
|
||||
@@ -94,6 +98,11 @@ class UiStore {
|
||||
}
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarResizing = (sidebarIsResizing: boolean): void => {
|
||||
this.sidebarIsResizing = sidebarIsResizing;
|
||||
};
|
||||
|
||||
@action
|
||||
setActiveCollection = (collection: Collection): void => {
|
||||
this.activeCollectionId = collection.id;
|
||||
@@ -110,6 +119,11 @@ class UiStore {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (sidebarWidth: number): void => {
|
||||
this.sidebarWidth = sidebarWidth;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseSidebar = () => {
|
||||
this.sidebarCollapsed = true;
|
||||
@@ -168,13 +182,15 @@ class UiStore {
|
||||
@action
|
||||
showToast = (
|
||||
message: string,
|
||||
options?: {
|
||||
type?: "warning" | "error" | "info" | "success",
|
||||
options: {
|
||||
type: "warning" | "error" | "info" | "success",
|
||||
timeout?: number,
|
||||
action?: {
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
},
|
||||
} = {
|
||||
type: "info",
|
||||
}
|
||||
) => {
|
||||
if (!message) return;
|
||||
@@ -190,7 +206,14 @@ class UiStore {
|
||||
|
||||
const id = v4();
|
||||
const createdAt = new Date().toISOString();
|
||||
this.toasts.set(id, { message, createdAt, id, ...options });
|
||||
this.toasts.set(id, {
|
||||
id,
|
||||
message,
|
||||
createdAt,
|
||||
type: options.type,
|
||||
timeout: options.timeout,
|
||||
action: options.action,
|
||||
});
|
||||
this.lastToastId = id;
|
||||
return id;
|
||||
};
|
||||
@@ -219,6 +242,7 @@ class UiStore {
|
||||
return JSON.stringify({
|
||||
tocVisible: this.tocVisible,
|
||||
sidebarCollapsed: this.sidebarCollapsed,
|
||||
sidebarWidth: this.sidebarWidth,
|
||||
languagePromptDismissed: this.languagePromptDismissed,
|
||||
theme: this.theme,
|
||||
});
|
||||
|
||||
+6
-6
@@ -11,7 +11,7 @@ export type LocationWithState = Location & {
|
||||
},
|
||||
};
|
||||
|
||||
export type Toast = {
|
||||
export type Toast = {|
|
||||
id: string,
|
||||
createdAt: string,
|
||||
message: string,
|
||||
@@ -22,7 +22,7 @@ export type Toast = {
|
||||
text: string,
|
||||
onClick: () => void,
|
||||
},
|
||||
};
|
||||
|};
|
||||
|
||||
export type FetchOptions = {
|
||||
prefetch?: boolean,
|
||||
@@ -31,12 +31,12 @@ export type FetchOptions = {
|
||||
force?: boolean,
|
||||
};
|
||||
|
||||
export type NavigationNode = {
|
||||
export type NavigationNode = {|
|
||||
id: string,
|
||||
title: string,
|
||||
url: string,
|
||||
children: NavigationNode[],
|
||||
};
|
||||
|};
|
||||
|
||||
// Pagination response in an API call
|
||||
export type Pagination = {
|
||||
@@ -46,12 +46,12 @@ export type Pagination = {
|
||||
};
|
||||
|
||||
// Pagination request params
|
||||
export type PaginationParams = {
|
||||
export type PaginationParams = {|
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
sort?: string,
|
||||
direction?: "ASC" | "DESC",
|
||||
};
|
||||
|};
|
||||
|
||||
export type SearchResult = {
|
||||
ranking: number,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user