Compare commits

...

62 Commits

Author SHA1 Message Date
Translate-O-Tron 481605d017 New Crowdin updates (#1876)
* fix: New Russian translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]
2021-02-18 18:48:53 -08:00
Translate-O-Tron 985ba9be29 fix: New Chinese Simplified translations from Crowdin [ci skip] (#1869) 2021-02-07 10:32:28 -08:00
Translate-O-Tron 8412efcd0c New Crowdin updates (#1864)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-02-04 18:19:29 -08:00
Tom Moor fb0b38fb71 fix: Mobile menu toggle button appearing in print media, closes #377 2021-02-02 20:57:08 -08:00
Tom Moor 8ff2f41068 Merge branch 'develop' of github.com:outline/outline into develop 2021-02-01 21:13:51 -08:00
Tom Moor 334dce7984 chore: Add Timing-Allow-Origin header (#1860) 2021-02-01 21:13:44 -08:00
Tom Moor 61b303831f fix: Document history sidebar layout issue 2021-02-01 21:13:31 -08:00
Tom Moor a9d60d288e feat: Automatically scroll to active item in sidebar (#1858) 2021-02-01 19:29:54 -08:00
Translate-O-Tron 3f267d7745 New Crowdin updates (#1848) 2021-01-31 23:24:10 -08:00
Tom Moor e845652cb8 flow: Convert to different <Trans> component syntax for flow compatability 2021-01-31 21:14:14 -08:00
Tom Moor 7066a45323 feat: Improve star/unstarred iconography 2021-01-31 20:53:27 -08:00
Tom Moor 654fdf1c7e fix: Guard unset language 2021-01-31 15:41:33 -08:00
Tom Moor 2a5fd0b332 test 2021-01-31 14:47:28 -08:00
Tom Moor 9ba63c6054 feat: Show nested document count on document list items on collection home 2021-01-31 14:41:18 -08:00
Tom Moor 785e208c6c lint 2021-01-31 14:41:00 -08:00
Tom Moor 9d84652dff fix: Frontend translation library expects dash separated, not underscore separated languages – this fix is required to enable working pluralization 2021-01-31 14:40:50 -08:00
Tom Moor ef6ce72cf5 fix: Recently published redirect 2021-01-31 13:01:56 -08:00
Tom Moor 7777cccf3b fix: Save regression from flow refactor 2021-01-31 12:53:52 -08:00
Tom Moor 620e4942d8 feat: Update default collection tab (#1821)
* feat: Allow listing root level documents only via documents.list

* feat: New tab on collection home

* update tab layout

* fix: Correctly sort index sorted documents.list

* revert: Tab layout changes

* fix: Missing route for recently published
fix: Redirect unknown tabs
2021-01-31 12:37:27 -08:00
Tom Moor 91ee3e62f2 fix: Reassign user on unpublish (#1857)
* findOne -> findByPk
2021-01-30 18:31:08 -08:00
Tom Moor eeb7650941 fix: New documents should sort to the top of manually organized collection 2021-01-30 00:18:56 -08:00
Tom Moor ee57f1ccf5 fix 2021-01-29 23:59:48 -08:00
Tom Moor 32f0589190 chore: Upgrade flow (#1854)
* wip: upgrade flow

* chore: More sealed props improvements

* Final fixes
2021-01-29 21:36:09 -08:00
Tom Moor ce2b246e60 fix: auth.config request should only be made on Login screen (#1852) 2021-01-29 17:54:28 -08:00
Tom Moor ae13347d55 chore: Add flow support for M1 macs 2021-01-28 23:25:37 -08:00
Tom Moor 13205171d7 chore: Improve dev efficient on M1 Mac 2021-01-28 21:01:53 -08:00
Tom Moor a912ea24f6 chore: Remove references to window.Sentry 2021-01-27 22:56:40 -08:00
Tom Moor 6fa760688b fix: Adds support for VirtualHost style AWS S3 buckets (#1847)
* Bump aws-sdk

* support virtual host buckets

* fix

* fix: VirtualHost bucket without explicit AWS_S3_FORCE_PATH_STYLE=false
2021-01-27 07:46:43 -08:00
Tom Moor 2825df29de Merge branch 'develop' of github.com:outline/outline into develop 2021-01-25 20:48:42 -08:00
Translate-O-Tron 604e97a6ce New Crowdin updates (#1835)
* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New French translations from Crowdin [ci skip]

* fix: New Spanish translations from Crowdin [ci skip]

* fix: New German translations from Crowdin [ci skip]

* fix: New Japanese translations from Crowdin [ci skip]

* fix: New Portuguese translations from Crowdin [ci skip]

* fix: New Russian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Portuguese, Brazilian translations from Crowdin [ci skip]

* fix: New Korean translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Chinese Simplified translations from Crowdin [ci skip]
2021-01-25 20:48:18 -08:00
milesstoetzner dc1bc44c8f feat: Google Drawings Integration (#1814)
* add google drawings integration

* add google drawings image

* update google drawings image and regex

* allow query parameter in google drawings regex

* support CDN for google drawings image
2021-01-25 20:47:23 -08:00
Tom Moor 2a55e78722 fix: Improve some editor alignment 2021-01-25 20:36:20 -08:00
Tom Moor eaf8dc5a3c fix: Text highlight of link in dark mode is impossible to read
closes #1838
2021-01-24 22:47:27 -08:00
Tom Moor f89d5adc37 fix: Ellipisis left in translation string 2021-01-24 12:09:44 -08:00
Tom Moor 978a123122 fix: 16 linting warnings 2021-01-23 10:19:08 -08:00
Tom Moor 96e65f495e chore: Remove custom VisuallyHidden component 2021-01-23 09:47:02 -08:00
Tom Moor 4106f15450 fix: Content jump when leaving edit mode 2021-01-22 23:58:34 -08:00
Tom Moor b3cd78c833 chore: Enable parameterized route profiling 2021-01-22 23:02:12 -08:00
Tom Moor 7b87fea4f4 Merge branch 'develop' of github.com:outline/outline into develop 2021-01-22 21:16:37 -08:00
Tom Moor 7e9bcb0c37 fix: More missing a11y labels 2021-01-22 21:12:25 -08:00
Tom Moor f6370ccf6d chore: Sentry performance monitoring (#1841)
* Hook up performance monitoring

* lint
2021-01-22 20:42:45 -08:00
Tom Moor 11e1108f4a fix: Unneccessary ev.preventDefault 2021-01-22 20:40:26 -08:00
Tom Moor c9fdf48c33 chore: Add missing labels to buttons without text and search inputs 2021-01-22 19:31:30 -08:00
Tom Moor 6a206de6cd chore: Add meta description 2021-01-22 19:12:39 -08:00
Tom Moor c69b393776 fix: JS error when submitting invites from sidebar-triggered modal 2021-01-22 08:57:52 -08:00
Tom Moor 6e9c456147 isMetaKey -> isModKey 2021-01-21 07:28:10 -08:00
Tom Moor 70626ffff0 feat: Organize sidebar (#1834)
* chore: Flip chinese label in language select

* feat: Add settings to sidebar, organize secondary items to bottom
2021-01-21 07:22:20 -08:00
Translate-O-Tron 993aad004e fix: New Korean translations from Crowdin [ci skip] (#1833) 2021-01-21 07:21:23 -08:00
Tom Moor 6fa9e700c8 chore: Flip chinese label in language select 2021-01-20 23:20:06 -08:00
Tom Moor 836b2e310a chore: Missing translation hooks in settings sidebar 2021-01-20 23:13:51 -08:00
Tom Moor 24ecaa8ce4 chore: Reduce default menu width 2021-01-20 23:07:48 -08:00
Tom Moor 40491fafe9 fix: Document star/unstar from list item navigates to document (regression) 2021-01-20 23:07:39 -08:00
Tom Moor 111212b038 feat: Resizable sidebar (#1827)
* wip: First round on sidebar resizing

* feat: Saving setting, animation

* all requirements, refactoring needed

* lint

* refactor useResize

* some mobile improvements

* fix

* refactor
2021-01-20 23:00:14 -08:00
Translate-O-Tron 774c3534d8 fix: New Chinese Simplified translations from Crowdin [ci skip] (#1832) 2021-01-20 22:23:30 -08:00
Malek Hijazi 9759227d73 fix: upgrade command (#1830)
I tested this on the server. Running yarn upgrade will result in yarn self updating. To solve this issue we need to run yarn run upgrade.
2021-01-20 22:19:44 -08:00
Tom Moor f608872c11 chore: Add Chinese and Italian translations 2021-01-20 22:09:36 -08:00
Translate-O-Tron eff9544ef9 New Crowdin updates (#1810)
* fix: New Chinese Simplified translations from Crowdin [ci skip]

* fix: New Thai translations from Crowdin [ci skip]

* fix: New Italian translations from Crowdin [ci skip]
2021-01-20 21:36:03 -08:00
Tom Moor 22fb464b87 lint 2021-01-18 16:11:48 -08:00
Tom Moor 3bace8c9e4 fix: Restore DNS prefetching for static resources (#1820)
* fix: Restore DNS prefetching for static resources

* fix: CDN paths
feat: preload instead of prefetch for key bundles

* csp

* fix: Turns out prefetch-src is still behind a flag in Chrome, not publicly available yet
2021-01-18 15:48:46 -08:00
Tom Moor 27fca28450 fix: Account for rehydrated old users before language
closes #1819
2021-01-17 22:19:54 -08:00
Tom Moor afcce7a0ef fix: Add missing width/height tags to img 2021-01-17 21:49:51 -08:00
Tom Moor f33495dddc fix: Editor mod shortcuts not working on Windows
closes #1745
2021-01-17 18:32:32 -08:00
132 changed files with 2933 additions and 1239 deletions
+1
View File
@@ -18,6 +18,7 @@
[options]
emoji=true
sharedmemory.heap_size=3221225472
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=app
+2 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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]);
+6 -2
View File
@@ -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> {
+15 -3
View File
@@ -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",
+23
View File
@@ -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;
`;
+6 -3
View File
@@ -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;
+5 -2
View File
@@ -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;
+7 -2
View File
@@ -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;
+3
View File
@@ -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>
+14 -2
View File
@@ -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}&nbsp;
@@ -135,6 +141,12 @@ function DocumentMeta({
</strong>
</span>
)}
{showNestedDocuments && nestedDocumentsCount > 0 && (
<span>
&nbsp;&middot; {nestedDocumentsCount}{" "}
{t("nested document", { count: nestedDocumentsCount })}
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
+21 -4
View File
@@ -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;
+4 -3
View File
@@ -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) {
+2 -2
View File
@@ -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>
+5 -2
View File
@@ -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
View File
@@ -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> {
+2 -2
View File
@@ -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> {
+4
View File
@@ -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}
+5 -2
View File
@@ -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
+2 -2
View File
@@ -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}>
+14 -3
View File
@@ -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>{" "}
&middot; <a onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</a>
</Link>{" "}
&middot;{" "}
<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;
`;
+45 -7
View File
@@ -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;`}
`};
`;
+2
View File
@@ -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 = [];
+3 -3
View File
@@ -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} />;
}
}
+2 -2
View File
@@ -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 {
+8 -2
View File
@@ -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> {
+12
View File
@@ -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;
+54 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+164 -63
View File
@@ -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;
+3 -1
View File
@@ -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`
+8
View File
@@ -0,0 +1,8 @@
// @flow
import * as React from "react";
export const id = "skip-nav";
export default function SkipNavContent() {
return <div id={id} />;
}
+34
View File
@@ -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
View File
@@ -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>
);
}
+5 -2
View File
@@ -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 = (
+5 -2
View File
@@ -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;
+2 -2
View File
@@ -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() {
-13
View File
@@ -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;
+39
View File
@@ -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
/>
);
}
}
+29
View File
@@ -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
View File
@@ -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: {|
+11 -11
View File
@@ -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} />
));
+8
View File
@@ -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",
+9
View File
@@ -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;
}
+1 -1
View File
@@ -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;
}
+31
View File
@@ -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
View File
@@ -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 />
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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>
)}
+7 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+5 -3
View File
@@ -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),
-1
View File
@@ -27,7 +27,6 @@ function NewDocumentMenu() {
as={Link}
to={newDocumentUrl(collections.orderedData[0].id)}
icon={<PlusIcon />}
small
>
{t("New doc")}
</Button>
+1
View File
@@ -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")}>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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}
+9 -5
View File
@@ -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,
});
}
+2 -1
View File
@@ -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
View File
@@ -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"));
+2 -1
View File
@@ -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 (
+32 -7
View File
@@ -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> doesnt contain any
documents yet.
</Trans>
<Trans
defaults="<em>{{ collectionName }}</em> doesnt 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("Cant find the group youre 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 whos 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>
)}
+5 -1
View File
@@ -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 />
+1 -1
View File
@@ -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,
+13 -12
View File
@@ -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 && (
-9
View File
@@ -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;
+4 -4
View File
@@ -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>
+10 -10
View File
@@ -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 && (
+2 -2
View File
@@ -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>
&nbsp;&nbsp;&nbsp;
<a href={share.url} target="_blank">
<a href={share.url} target="_blank" rel="noreferrer">
Preview
</a>
</div>
+5 -1
View File
@@ -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 />
+5 -8
View File
@@ -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>
);
+3 -3
View File
@@ -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 whos 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
View File
@@ -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);
+5 -4
View File
@@ -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>
)}
+11 -11
View File
@@ -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)`
+3 -4
View File
@@ -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>
+5 -1
View File
@@ -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 />
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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
View File
@@ -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);
+34 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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