mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8168b52a2 | |||
| bfe10b1df0 |
@@ -9,7 +9,7 @@
|
||||
"version": "2",
|
||||
"proposals": true
|
||||
},
|
||||
"useBuiltIns": "usage"
|
||||
"useBuiltIns": "usage",
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:14
|
||||
- image: circleci/node:12
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
@@ -29,15 +29,12 @@ jobs:
|
||||
- run:
|
||||
name: migrate
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: lint
|
||||
command: yarn lint
|
||||
- run:
|
||||
name: flow
|
||||
command: yarn flow check --max-workers 4
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test
|
||||
- run:
|
||||
name: build
|
||||
command: yarn build
|
||||
command: yarn flow check --max-workers 4
|
||||
@@ -1,19 +0,0 @@
|
||||
__mocks__
|
||||
.git
|
||||
.vscode
|
||||
.github
|
||||
.circleci
|
||||
.DS_Store
|
||||
.env*
|
||||
.eslint*
|
||||
.flowconfig
|
||||
.log
|
||||
Makefile
|
||||
Procfile
|
||||
app.json
|
||||
build
|
||||
docker-compose.yml
|
||||
fakes3
|
||||
flow-typed
|
||||
node_modules
|
||||
setupJest.js
|
||||
@@ -45,7 +45,6 @@ AWS_REGION=xx-xxxx-x
|
||||
AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569
|
||||
AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here
|
||||
AWS_S3_UPLOAD_MAX_SIZE=26214400
|
||||
AWS_S3_FORCE_PATH_STYLE=true
|
||||
# uploaded s3 objects permission level, default is private
|
||||
# set to "public-read" to allow public access
|
||||
AWS_S3_ACL=private
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
"react-app",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
"plugin:flowtype/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
@@ -98,8 +97,5 @@
|
||||
},
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"globals": {
|
||||
"EDITOR_VERSION": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
dist
|
||||
build
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
|
||||
+7
-14
@@ -1,24 +1,17 @@
|
||||
FROM node:14-alpine
|
||||
FROM node:12-alpine
|
||||
|
||||
ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH
|
||||
ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules
|
||||
ENV APP_PATH /opt/outline
|
||||
RUN mkdir -p $APP_PATH
|
||||
|
||||
WORKDIR $APP_PATH
|
||||
COPY . $APP_PATH
|
||||
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN yarn build
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
|
||||
RUN yarn --pure-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn build && \
|
||||
yarn --production --ignore-scripts --prefer-offline && \
|
||||
rm -rf server && \
|
||||
rm -rf shared && \
|
||||
rm -rf app
|
||||
|
||||
ENV NODE_ENV production
|
||||
CMD yarn start
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.46.0
|
||||
Licensed Work: Outline 0.44.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2023-08-12
|
||||
Change Date: 2023-07-03
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -92,11 +92,6 @@
|
||||
"value": "26214400",
|
||||
"required": false
|
||||
},
|
||||
"AWS_S3_FORCE_PATH_STYLE": {
|
||||
"description": "Use path-style URL's for connecting to S3 instead of subdomain. This is useful for S3-compatible storage.",
|
||||
"value": "true",
|
||||
"required": false
|
||||
},
|
||||
"AWS_REGION": {
|
||||
"value": "us-east-1",
|
||||
"description": "Region in which the above S3 bucket exists",
|
||||
|
||||
@@ -9,7 +9,6 @@ type Props = {
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
padding: 60px 20px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -6,7 +6,7 @@ type Props = {
|
||||
children: React.Node,
|
||||
};
|
||||
|
||||
export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
export default function DelayedMount({ delay = 150, children }: Props) {
|
||||
const [isShowing, setShowing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -14,7 +14,7 @@ export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [delay]);
|
||||
}, []);
|
||||
|
||||
if (!isShowing) {
|
||||
return null;
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { action, observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { type Match, Redirect, type RouterHistory } from "react-router-dom";
|
||||
import { type RouterHistory, type Match } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import { ListPlaceholder } from "components/LoadingPlaceholder";
|
||||
import Revision from "./components/Revision";
|
||||
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
@@ -32,7 +29,6 @@ class DocumentHistory extends React.Component<Props> {
|
||||
@observable isFetching: boolean = false;
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
async componentDidMount() {
|
||||
await this.loadMoreResults();
|
||||
@@ -90,34 +86,15 @@ class DocumentHistory extends React.Component<Props> {
|
||||
return this.props.revisions.getDocumentRevisions(document.id);
|
||||
}
|
||||
|
||||
onCloseHistory = () => {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
|
||||
this.redirectTo = documentUrl(document);
|
||||
};
|
||||
|
||||
render() {
|
||||
const document = this.props.documents.getByUrl(
|
||||
this.props.match.params.documentSlug
|
||||
);
|
||||
const showLoading = (!this.isLoaded && this.isFetching) || !document;
|
||||
|
||||
if (this.redirectTo) return <Redirect to={this.redirectTo} push />;
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<Wrapper column>
|
||||
<Header>
|
||||
<Title>History</Title>
|
||||
<Button
|
||||
icon={<CloseIcon />}
|
||||
onClick={this.onCloseHistory}
|
||||
borderOnHover
|
||||
neutral
|
||||
/>
|
||||
</Header>
|
||||
{showLoading ? (
|
||||
<Loading>
|
||||
<ListPlaceholder count={5} />
|
||||
@@ -163,36 +140,10 @@ const Wrapper = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Sidebar = styled(Flex)`
|
||||
display: none;
|
||||
background: ${(props) => props.theme.background};
|
||||
min-width: ${(props) => props.theme.sidebarWidth};
|
||||
border-left: 1px solid ${(props) => props.theme.divider};
|
||||
z-index: 1;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: flex;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Title = styled(Flex)`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: 0;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
color: ${(props) => props.theme.text};
|
||||
`;
|
||||
|
||||
export default inject("documents", "revisions")(DocumentHistory);
|
||||
|
||||
@@ -169,7 +169,6 @@ const DocumentLink = styled(Link)`
|
||||
border-radius: 8px;
|
||||
max-height: 50vh;
|
||||
min-width: 100%;
|
||||
max-width: calc(100vw - 40px);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ const Label = styled(Flex).attrs({
|
||||
justify: "center",
|
||||
align: "center",
|
||||
})`
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
z-index: 1000;
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
@@ -244,7 +244,7 @@ const Position = styled.div`
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ bottom }) => (bottom !== undefined ? `bottom: ${bottom}px` : "")};
|
||||
max-height: 75%;
|
||||
z-index: ${(props) => props.theme.depths.menu};
|
||||
z-index: 1000;
|
||||
transform: ${(props) =>
|
||||
props.position === "center" ? "translateX(-50%)" : "initial"};
|
||||
pointer-events: none;
|
||||
|
||||
+11
-15
@@ -2,16 +2,14 @@
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { withRouter, type RouterHistory } from "react-router-dom";
|
||||
import RichMarkdownEditor from "rich-markdown-editor";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import embeds from "../embeds";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { uploadFile } from "utils/uploadFile";
|
||||
|
||||
const RichMarkdownEditor = React.lazy(() => import("rich-markdown-editor"));
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
type Props = {
|
||||
@@ -24,7 +22,7 @@ type Props = {
|
||||
};
|
||||
|
||||
type PropsWithRef = Props & {
|
||||
forwardedRef: React.Ref<any>,
|
||||
forwardedRef: React.Ref<RichMarkdownEditor>,
|
||||
history: RouterHistory,
|
||||
};
|
||||
|
||||
@@ -69,17 +67,15 @@ class Editor extends React.Component<PropsWithRef> {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<StyledEditor
|
||||
ref={this.props.forwardedRef}
|
||||
uploadImage={this.onUploadImage}
|
||||
onClickLink={this.onClickLink}
|
||||
onShowToast={this.onShowToast}
|
||||
embeds={this.props.disableEmbeds ? EMPTY_ARRAY : embeds}
|
||||
tooltip={EditorTooltip}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import env from "env";
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
reloadOnChunkMissing?: boolean,
|
||||
};
|
||||
|
||||
@observer
|
||||
@@ -24,25 +23,13 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
this.error = error;
|
||||
console.error(error);
|
||||
|
||||
if (
|
||||
this.props.reloadOnChunkMissing &&
|
||||
error.message &&
|
||||
error.message.match(/chunk/)
|
||||
) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.Sentry) {
|
||||
window.Sentry.captureException(error);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload(true);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleShowDetails = () => {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Empty from "components/Empty";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
export default function FullscreenLoading() {
|
||||
return (
|
||||
<Fade timing={500}>
|
||||
<Centered>
|
||||
<Empty>Loading…</Empty>
|
||||
</Centered>
|
||||
</Fade>
|
||||
);
|
||||
}
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`;
|
||||
@@ -4,7 +4,6 @@ import styled from "styled-components";
|
||||
const Heading = styled.h1`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
${(props) => (props.centered ? "text-align: center;" : "")}
|
||||
|
||||
svg {
|
||||
margin-left: -6px;
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as React from "react";
|
||||
import { Portal } from "react-portal";
|
||||
import styled from "styled-components";
|
||||
import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
@@ -20,7 +20,12 @@ type Props = {
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
|
||||
function HoverPreview({ node, documents, onClose, event }: Props) {
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = parseDocumentSlugFromUrl(node.href);
|
||||
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
@@ -126,15 +131,6 @@ function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function HoverPreview({ node, ...rest }: Props) {
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HoverPreviewInternal {...rest} node={node} />;
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideIn} 150ms ease;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentSlug";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
@@ -29,14 +29,12 @@ function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews isDraft={document.isDraft} document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
disableEmbeds
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
disableEmbeds
|
||||
readOnly
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,17 +22,13 @@ import {
|
||||
VehicleIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import styled from "styled-components";
|
||||
import { DropdownMenu } from "components/DropdownMenu";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText } from "components/Input";
|
||||
import NudeButton from "components/NudeButton";
|
||||
|
||||
const TwitterPicker = React.lazy(() =>
|
||||
import("react-color/lib/components/twitter/Twitter")
|
||||
);
|
||||
|
||||
export const icons = {
|
||||
collection: {
|
||||
component: CollectionIcon,
|
||||
@@ -197,16 +193,14 @@ class IconPicker extends React.Component<Props> {
|
||||
})}
|
||||
</Icons>
|
||||
<Flex onClick={preventEventBubble}>
|
||||
<React.Suspense fallback={<Loading>Loading…</Loading>}>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</React.Suspense>
|
||||
<ColorPicker
|
||||
color={this.props.color}
|
||||
onChange={(color) =>
|
||||
this.props.onChange(color.hex, this.props.icon)
|
||||
}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</Flex>
|
||||
</DropdownMenu>
|
||||
</Wrapper>
|
||||
@@ -232,10 +226,6 @@ const IconButton = styled(NudeButton)`
|
||||
height: 30px;
|
||||
`;
|
||||
|
||||
const Loading = styled(HelpText)`
|
||||
padding: 16px;
|
||||
`;
|
||||
|
||||
const ColorPicker = styled(TwitterPicker)`
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
@@ -4,8 +4,6 @@ import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Editor from "components/Editor";
|
||||
import HelpText from "components/HelpText";
|
||||
import { LabelText, Outline } from "components/Input";
|
||||
|
||||
type Props = {
|
||||
@@ -21,6 +19,10 @@ class InputRich extends React.Component<Props> {
|
||||
@observable editorComponent: React.ComponentType<any>;
|
||||
@observable focused: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
handleBlur = () => {
|
||||
this.focused = false;
|
||||
};
|
||||
@@ -29,18 +31,36 @@ class InputRich extends React.Component<Props> {
|
||||
this.focused = true;
|
||||
};
|
||||
|
||||
loadEditor = async () => {
|
||||
try {
|
||||
const EditorImport = await import("./Editor");
|
||||
this.editorComponent = EditorImport.default;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.match(/chunk/)) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { label, minHeight, maxHeight, ui, ...rest } = this.props;
|
||||
const Editor = this.editorComponent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LabelText>{label}</LabelText>
|
||||
|
||||
<StyledOutline
|
||||
maxHeight={maxHeight}
|
||||
minHeight={minHeight}
|
||||
focused={this.focused}
|
||||
>
|
||||
<React.Suspense fallback={<HelpText>Loading editor…</HelpText>}>
|
||||
{Editor ? (
|
||||
<Editor
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
@@ -48,7 +68,9 @@ class InputRich extends React.Component<Props> {
|
||||
grow
|
||||
{...rest}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
"Loading…"
|
||||
)}
|
||||
</StyledOutline>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -44,22 +44,21 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
constructor(props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
componentWillMount() {
|
||||
this.updateBackground();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground(this.props);
|
||||
this.updateBackground();
|
||||
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground(props) {
|
||||
updateBackground() {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = props.theme.background;
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
}
|
||||
|
||||
@keydown("shift+/")
|
||||
|
||||
@@ -18,7 +18,7 @@ const loadingFrame = keyframes`
|
||||
const Container = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: ${(props) => props.theme.depths.loadingIndicatorBar};
|
||||
z-index: 9999;
|
||||
|
||||
background-color: #03a9f4;
|
||||
width: 100%;
|
||||
|
||||
@@ -17,8 +17,7 @@ class Mask extends React.Component<Props> {
|
||||
return false;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
componentWillMount() {
|
||||
this.width = randomInteger(75, 100);
|
||||
}
|
||||
|
||||
|
||||
+18
-26
@@ -9,7 +9,6 @@ import breakpoint from "styled-components-breakpoint";
|
||||
import { fadeAndScaleIn } from "shared/styles/animations";
|
||||
import Flex from "components/Flex";
|
||||
import NudeButton from "components/NudeButton";
|
||||
import Scrollable from "components/Scrollable";
|
||||
|
||||
ReactModal.setAppElement("#root");
|
||||
|
||||
@@ -24,12 +23,11 @@ const GlobalStyles = createGlobalStyle`
|
||||
.ReactModal__Overlay {
|
||||
background-color: ${(props) =>
|
||||
transparentize(0.25, props.theme.background)} !important;
|
||||
z-index: ${(props) => props.theme.depths.modalOverlay};
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
.ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 12px;
|
||||
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
|
||||
@@ -38,15 +36,13 @@ const GlobalStyles = createGlobalStyle`
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 36px;
|
||||
}
|
||||
@@ -76,11 +72,10 @@ const Modal = ({
|
||||
isOpen={isOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
<Content onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
|
||||
{children}
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
@@ -94,20 +89,10 @@ const Modal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 2rem 2rem;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding-top: 13vh;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
const Content = styled(Flex)`
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const StyledModal = styled(ReactModal)`
|
||||
@@ -118,13 +103,20 @@ const StyledModal = styled(ReactModal)`
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: ${(props) => props.theme.depths.modal};
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
padding: 8vh 2rem 2rem;
|
||||
outline: none;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding-top: 13vh;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Text = styled.span`
|
||||
@@ -155,7 +147,7 @@ const Close = styled(NudeButton)`
|
||||
`;
|
||||
|
||||
const Back = styled(NudeButton)`
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
display: none;
|
||||
align-items: center;
|
||||
top: 2rem;
|
||||
|
||||
@@ -42,23 +42,20 @@ class PathToDocument extends React.Component<Props> {
|
||||
return (
|
||||
<Component ref={ref} onClick={this.handleClick} href="" selectable>
|
||||
{collection && <CollectionIcon collection={collection} />}
|
||||
|
||||
{result.path
|
||||
.map((doc) => <Title key={doc.id}>{doc.title}</Title>)
|
||||
.reduce((prev, curr) => [prev, <StyledGoToIcon />, curr])}
|
||||
{document && (
|
||||
<DocumentTitle>
|
||||
<Flex>
|
||||
{" "}
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</DocumentTitle>
|
||||
</Flex>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DocumentTitle = styled(Flex)``;
|
||||
|
||||
const Title = styled.span`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -66,7 +63,7 @@ const Title = styled.span`
|
||||
`;
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)`
|
||||
fill: ${(props) => props.theme.divider};
|
||||
opacity: 0.25;
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
@@ -82,20 +79,13 @@ const ResultWrapper = styled.div`
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
margin: 0 -8px;
|
||||
padding: 8px 4px;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: none;
|
||||
}
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const StyledPopover = styled(BoundlessPopover)`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: ${(props) => props.theme.depths.popover};
|
||||
z-index: 9999;
|
||||
|
||||
svg {
|
||||
height: 16px;
|
||||
|
||||
@@ -40,7 +40,7 @@ type Props = {
|
||||
@observer
|
||||
class SettingsSidebar extends React.Component<Props> {
|
||||
returnToDashboard = () => {
|
||||
this.props.history.push("/home");
|
||||
this.props.history.push("/");
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,60 +1,65 @@
|
||||
// @flow
|
||||
import { observer } from "mobx-react";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { CloseIcon, MenuIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import type { Location } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import usePrevious from "hooks/usePrevious";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
let firstRender = true;
|
||||
|
||||
type Props = {
|
||||
children: React.Node,
|
||||
location: Location,
|
||||
ui: UiStore,
|
||||
};
|
||||
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
@observer
|
||||
class Sidebar extends React.Component<Props> {
|
||||
componentWillReceiveProps = (nextProps: Props) => {
|
||||
if (this.props.location !== nextProps.location) {
|
||||
this.props.ui.hideMobileSidebar();
|
||||
}
|
||||
}, [ui, location]);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
column
|
||||
>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
toggleSidebar = () => {
|
||||
this.props.ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, ui } = this.props;
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
column
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
<Toggle
|
||||
onClick={this.toggleSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Fade in the sidebar on first render after page load
|
||||
if (firstRender) {
|
||||
firstRender = false;
|
||||
return <Fade>{content}</Fade>;
|
||||
// Fade in the sidebar on first render after page load
|
||||
if (firstRender) {
|
||||
firstRender = false;
|
||||
return <Fade>{content}</Fade>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -66,7 +71,7 @@ const Container = styled(Flex)`
|
||||
transition: left 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
margin-left: ${(props) => (props.mobileSidebarVisible ? 0 : "-100%")};
|
||||
z-index: ${(props) => props.theme.depths.sidebar};
|
||||
z-index: 1000;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
@@ -112,4 +117,4 @@ const Toggle = styled.a`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(observer(Sidebar));
|
||||
export default withRouter(inject("ui")(Sidebar));
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
import TeamLogo from "components/TeamLogo";
|
||||
|
||||
type Props = {
|
||||
teamName: string,
|
||||
subheading: React.Node,
|
||||
subheading: string,
|
||||
showDisclosure?: boolean,
|
||||
logoUrl: string,
|
||||
theme: Object,
|
||||
};
|
||||
|
||||
function HeaderBlock({
|
||||
@@ -17,6 +18,7 @@ function HeaderBlock({
|
||||
teamName,
|
||||
subheading,
|
||||
logoUrl,
|
||||
theme,
|
||||
...rest
|
||||
}: Props) {
|
||||
return (
|
||||
@@ -25,7 +27,7 @@ function HeaderBlock({
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
{showDisclosure && <StyledExpandedIcon color="currentColor" />}
|
||||
{showDisclosure && <StyledExpandedIcon color={theme.text} />}
|
||||
</TeamName>
|
||||
<Subheading>{subheading}</Subheading>
|
||||
</Flex>
|
||||
@@ -71,4 +73,4 @@ const Header = styled(Flex)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default HeaderBlock;
|
||||
export default withTheme(HeaderBlock);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @flow
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -24,80 +25,79 @@ type Props = {
|
||||
depth?: number,
|
||||
};
|
||||
|
||||
function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
menu,
|
||||
menuOpen,
|
||||
hideDisclosure,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
...rest
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = React.useState(rest.expanded);
|
||||
@observer
|
||||
class SidebarLink extends React.Component<Props> {
|
||||
@observable expanded: ?boolean = this.props.expanded;
|
||||
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
}, [depth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rest.expanded !== undefined) {
|
||||
setExpanded(rest.expanded);
|
||||
}
|
||||
}, [rest.expanded]);
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
(ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
},
|
||||
[expanded]
|
||||
);
|
||||
|
||||
const handleExpand = React.useCallback(() => {
|
||||
setExpanded(true);
|
||||
}, []);
|
||||
|
||||
const showDisclosure = !!children && !hideDisclosure;
|
||||
const activeStyle = {
|
||||
color: theme.text,
|
||||
background: theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
...style,
|
||||
style = {
|
||||
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper column>
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : style}
|
||||
onClick={onClick}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label onClick={handleExpand}>
|
||||
{showDisclosure && (
|
||||
<Disclosure expanded={expanded} onClick={handleClick} />
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
{expanded && children}
|
||||
</Wrapper>
|
||||
);
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
if (nextProps.expanded !== undefined) {
|
||||
this.expanded = nextProps.expanded;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleClick = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
this.expanded = !this.expanded;
|
||||
};
|
||||
|
||||
@action
|
||||
handleExpand = () => {
|
||||
this.expanded = true;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
menu,
|
||||
menuOpen,
|
||||
hideDisclosure,
|
||||
exact,
|
||||
href,
|
||||
} = this.props;
|
||||
const showDisclosure = !!children && !hideDisclosure;
|
||||
const activeStyle = {
|
||||
color: this.props.theme.text,
|
||||
background: this.props.theme.sidebarItemBackground,
|
||||
fontWeight: 600,
|
||||
...this.style,
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper column>
|
||||
<StyledNavLink
|
||||
activeStyle={activeStyle}
|
||||
style={active ? activeStyle : this.style}
|
||||
onClick={onClick}
|
||||
exact={exact !== false}
|
||||
to={to}
|
||||
as={to ? undefined : href ? "a" : "div"}
|
||||
href={href}
|
||||
>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label onClick={this.handleExpand}>
|
||||
{showDisclosure && (
|
||||
<Disclosure expanded={this.expanded} onClick={this.handleClick} />
|
||||
)}
|
||||
{label}
|
||||
</Label>
|
||||
{menu && <Action menuOpen={menuOpen}>{menu}</Action>}
|
||||
</StyledNavLink>
|
||||
{this.expanded && children}
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// accounts for whitespace around icon
|
||||
@@ -171,4 +171,4 @@ const Disclosure = styled(CollapsedIcon)`
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(observer(SidebarLink)));
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { find } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import io, { Socket } from "socket.io-client";
|
||||
import io from "socket.io-client";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
@@ -13,7 +13,6 @@ import MembershipsStore from "stores/MembershipsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import { getVisibilityListener, getPageVisible } from "utils/pageVisibility";
|
||||
|
||||
export const SocketContext: any = React.createContext();
|
||||
|
||||
@@ -32,42 +31,12 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class SocketProvider extends React.Component<Props> {
|
||||
@observable socket: Socket;
|
||||
@observable socket;
|
||||
|
||||
componentDidMount() {
|
||||
this.createConnection();
|
||||
|
||||
document.addEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
checkConnection = () => {
|
||||
if (this.socket && this.socket.disconnected && getPageVisible()) {
|
||||
// null-ifying this reference is important, do not remove. Without it
|
||||
// references to old sockets are potentially held in context
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
|
||||
this.createConnection();
|
||||
}
|
||||
};
|
||||
|
||||
createConnection = () => {
|
||||
this.socket = io(window.location.origin, {
|
||||
path: "/realtime",
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
});
|
||||
|
||||
this.socket.authenticated = false;
|
||||
|
||||
const {
|
||||
@@ -96,12 +65,12 @@ class SocketProvider extends React.Component<Props> {
|
||||
// when the socket is disconnected we need to clear all presence state as
|
||||
// it's no longer reliable.
|
||||
presence.clear();
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.on("reconnect_attempt", () => {
|
||||
this.socket.io.opts.transports = ["polling", "websocket"];
|
||||
if (reason === "io server disconnect") {
|
||||
// the disconnection was initiated by the server, need to reconnect
|
||||
// manually, else the socket will automatically try to reconnect
|
||||
this.socket.connect();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
@@ -291,7 +260,14 @@ class SocketProvider extends React.Component<Props> {
|
||||
this.socket.on("user.presence", (event) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket.authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,6 @@ const Tabs = styled.nav`
|
||||
border-bottom: 1px solid ${(props) => props.theme.divider};
|
||||
margin-top: 22px;
|
||||
margin-bottom: 12px;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const Separator = styled.span`
|
||||
|
||||
@@ -34,7 +34,7 @@ const List = styled.ol`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
z-index: ${(props) => props.theme.depths.toasts};
|
||||
z-index: 1000;
|
||||
`;
|
||||
|
||||
export default inject("ui")(Toasts);
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function usePrevious(value: any) {
|
||||
const ref = React.useRef();
|
||||
React.useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// @flow
|
||||
import { MobXProviderContext } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import RootStore from "stores";
|
||||
|
||||
export default function useStores(): typeof RootStore {
|
||||
return React.useContext(MobXProviderContext);
|
||||
}
|
||||
+22
-15
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import "mobx-react-lite/batchingForReactDom";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
@@ -13,24 +12,32 @@ import Toasts from "components/Toasts";
|
||||
import Routes from "./routes";
|
||||
import env from "env";
|
||||
|
||||
let DevTools;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
DevTools = require("mobx-react-devtools").default; // eslint-disable-line global-require
|
||||
}
|
||||
|
||||
const element = document.getElementById("root");
|
||||
|
||||
if (element) {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>,
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>
|
||||
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
|
||||
</>,
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,6 @@ class CollectionMenu extends React.Component<Props> {
|
||||
collection={collection}
|
||||
onSubmit={this.handleMembersModalClose}
|
||||
handleEditCollectionOpen={this.handleEditCollectionOpen}
|
||||
onEdit={this.handleEditCollectionOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<DropdownMenu onOpen={onOpen} onClose={onClose} position={position}>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default class Collection extends BaseModel {
|
||||
icon: string;
|
||||
color: string;
|
||||
private: boolean;
|
||||
type: "atlas" | "journal";
|
||||
documents: NavigationNode[];
|
||||
createdAt: ?string;
|
||||
updatedAt: ?string;
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import Dashboard from "scenes/Dashboard";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
import Login from "scenes/Login";
|
||||
import Search from "scenes/Search";
|
||||
import Settings from "scenes/Settings";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Events from "scenes/Settings/Events";
|
||||
import Export from "scenes/Settings/Export";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
import People from "scenes/Settings/People";
|
||||
import Security from "scenes/Settings/Security";
|
||||
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 Starred from "scenes/Starred";
|
||||
import Templates from "scenes/Templates";
|
||||
import Trash from "scenes/Trash";
|
||||
|
||||
import Authenticated from "components/Authenticated";
|
||||
import Layout from "components/Layout";
|
||||
import SocketProvider from "components/SocketProvider";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
<Redirect
|
||||
to={
|
||||
match.params.documentSlug ? `/doc/${match.params.documentSlug}` : "/home"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function Routes() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/" component={Login} />
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Authenticated>
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Dashboard} />
|
||||
<Route path="/home" component={Dashboard} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
<Route exact path="/settings/events" component={Events} />
|
||||
<Route
|
||||
exact
|
||||
path="/settings/notifications"
|
||||
component={Notifications}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/slack"
|
||||
component={Slack}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/integrations/zapier"
|
||||
component={Zapier}
|
||||
/>
|
||||
<Route exact path="/settings/export" component={Export} />
|
||||
<Route
|
||||
exact
|
||||
path="/collections/:id/new"
|
||||
component={DocumentNew}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/collections/:id/:tab"
|
||||
component={Collection}
|
||||
/>
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/edit`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
</Authenticated>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route, Redirect, type Match } from "react-router-dom";
|
||||
import Archive from "scenes/Archive";
|
||||
import Collection from "scenes/Collection";
|
||||
import Dashboard from "scenes/Dashboard";
|
||||
import KeyedDocument from "scenes/Document/KeyedDocument";
|
||||
import DocumentNew from "scenes/DocumentNew";
|
||||
import Drafts from "scenes/Drafts";
|
||||
import Error404 from "scenes/Error404";
|
||||
import Search from "scenes/Search";
|
||||
import Starred from "scenes/Starred";
|
||||
import Templates from "scenes/Templates";
|
||||
import Trash from "scenes/Trash";
|
||||
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Layout from "components/Layout";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import SocketProvider from "components/SocketProvider";
|
||||
import { matchDocumentSlug as slug } from "utils/routeHelpers";
|
||||
|
||||
const SettingsRoutes = React.lazy(() => import("./settings"));
|
||||
|
||||
const NotFound = () => <Search notFound />;
|
||||
const RedirectDocument = ({ match }: { match: Match }) => (
|
||||
<Redirect
|
||||
to={
|
||||
match.params.documentSlug ? `/doc/${match.params.documentSlug}` : "/home"
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export default function AuthenticatedRoutes() {
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
<Switch>
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Dashboard} />
|
||||
<Route path="/home" component={Dashboard} />
|
||||
<Route exact path="/starred" component={Starred} />
|
||||
<Route exact path="/starred/:sort" component={Starred} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Route exact path="/collections/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collections/:id/:tab" component={Collection} />
|
||||
<Route exact path="/collections/:id" component={Collection} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={KeyedDocument}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
|
||||
<Route path={`/doc/${slug}`} component={KeyedDocument} />
|
||||
<Route exact path="/search" component={Search} />
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<LoadingPlaceholder />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<SettingsRoutes />
|
||||
</React.Suspense>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import DelayedMount from "components/DelayedMount";
|
||||
import FullscreenLoading from "components/FullscreenLoading";
|
||||
|
||||
const Authenticated = React.lazy(() => import("components/Authenticated"));
|
||||
const AuthenticatedRoutes = React.lazy(() => import("./authenticated"));
|
||||
const KeyedDocument = React.lazy(() => import("scenes/Document/KeyedDocument"));
|
||||
const Login = React.lazy(() => import("scenes/Login"));
|
||||
|
||||
export default function Routes() {
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<DelayedMount delay={2000}>
|
||||
<FullscreenLoading />
|
||||
</DelayedMount>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Route exact path="/" component={Login} />
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/share/:shareId" component={KeyedDocument} />
|
||||
<Authenticated>
|
||||
<AuthenticatedRoutes />
|
||||
</Authenticated>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Switch, Route } from "react-router-dom";
|
||||
import Settings from "scenes/Settings";
|
||||
import Details from "scenes/Settings/Details";
|
||||
import Events from "scenes/Settings/Events";
|
||||
import Export from "scenes/Settings/Export";
|
||||
import Groups from "scenes/Settings/Groups";
|
||||
import Notifications from "scenes/Settings/Notifications";
|
||||
import People from "scenes/Settings/People";
|
||||
import Security from "scenes/Settings/Security";
|
||||
import Shares from "scenes/Settings/Shares";
|
||||
import Slack from "scenes/Settings/Slack";
|
||||
import Tokens from "scenes/Settings/Tokens";
|
||||
import Zapier from "scenes/Settings/Zapier";
|
||||
|
||||
export default function SettingsRoutes() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route exact path="/settings" component={Settings} />
|
||||
<Route exact path="/settings/details" component={Details} />
|
||||
<Route exact path="/settings/security" component={Security} />
|
||||
<Route exact path="/settings/people" component={People} />
|
||||
<Route exact path="/settings/people/:filter" component={People} />
|
||||
<Route exact path="/settings/groups" component={Groups} />
|
||||
<Route exact path="/settings/shares" component={Shares} />
|
||||
<Route exact path="/settings/tokens" component={Tokens} />
|
||||
<Route exact path="/settings/events" component={Events} />
|
||||
<Route exact path="/settings/notifications" component={Notifications} />
|
||||
<Route exact path="/settings/integrations/slack" component={Slack} />
|
||||
<Route exact path="/settings/integrations/zapier" component={Zapier} />
|
||||
<Route exact path="/settings/export" component={Export} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
+11
-12
@@ -5,6 +5,7 @@ import { observer, inject } from "mobx-react";
|
||||
import { NewDocumentIcon, PlusIcon, PinIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Redirect, Link, Switch, Route, type Match } from "react-router-dom";
|
||||
import RichMarkdownEditor from "rich-markdown-editor";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
@@ -21,7 +22,6 @@ import Button from "components/Button";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import DocumentList from "components/DocumentList";
|
||||
import Editor from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import Heading from "components/Heading";
|
||||
import HelpText from "components/HelpText";
|
||||
@@ -62,10 +62,10 @@ class CollectionScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const { id } = this.props.match.params;
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { id } = nextProps.match.params;
|
||||
|
||||
if (id && id !== prevProps.match.params.id) {
|
||||
if (id && id !== this.props.match.params.id) {
|
||||
this.loadContent(id);
|
||||
}
|
||||
}
|
||||
@@ -218,14 +218,13 @@ class CollectionScene extends React.Component<Props> {
|
||||
</Heading>
|
||||
|
||||
{collection.description && (
|
||||
<React.Suspense fallback={<p>Loading…</p>}>
|
||||
<Editor
|
||||
id={collection.id}
|
||||
key={collection.description}
|
||||
defaultValue={collection.description}
|
||||
readOnly
|
||||
/>
|
||||
</React.Suspense>
|
||||
<RichMarkdownEditor
|
||||
id={collection.id}
|
||||
key={collection.description}
|
||||
defaultValue={collection.description}
|
||||
theme={theme}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasPinnedDocuments && (
|
||||
|
||||
@@ -132,7 +132,7 @@ class CollectionMembers extends React.Component<Props> {
|
||||
collection. You can make this collection visible to the entire
|
||||
team by{" "}
|
||||
<a role="button" onClick={this.props.onEdit}>
|
||||
changing the visibility
|
||||
changing its visibility
|
||||
</a>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
@@ -64,6 +64,12 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
goToDocumentCanonical = () => {
|
||||
if (this.document) {
|
||||
this.props.history.push(this.document.url);
|
||||
}
|
||||
};
|
||||
|
||||
get isEditing() {
|
||||
return this.props.match.path === matchDocumentEdit;
|
||||
}
|
||||
@@ -119,28 +125,17 @@ class DataLoader extends React.Component<Props> {
|
||||
const document = this.document;
|
||||
|
||||
if (document) {
|
||||
const can = this.props.policies.abilities(document.id);
|
||||
|
||||
// sets the document as active in the sidebar, ideally in the future this
|
||||
// will be route driven.
|
||||
this.props.ui.setActiveDocument(document);
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && this.isEditing) {
|
||||
this.props.history.push(document.url);
|
||||
return;
|
||||
if (document.isArchived && this.isEditing) {
|
||||
return this.goToDocumentCanonical();
|
||||
}
|
||||
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read) {
|
||||
this.props.shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
this.props.shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const isMove = this.props.location.pathname.match(/move$/);
|
||||
const canRedirect = !revisionId && !isMove && !shareId;
|
||||
|
||||
@@ -18,16 +18,15 @@ import Branding from "components/Branding";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import Flex from "components/Flex";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import Notice from "components/Notice";
|
||||
import PageTitle from "components/PageTitle";
|
||||
import Time from "components/Time";
|
||||
import Container from "./Container";
|
||||
import Contents from "./Contents";
|
||||
import DocumentMove from "./DocumentMove";
|
||||
import Editor from "./Editor";
|
||||
import Header from "./Header";
|
||||
import KeyboardShortcutsButton from "./KeyboardShortcutsButton";
|
||||
import Loading from "./Loading";
|
||||
import MarkAsViewed from "./MarkAsViewed";
|
||||
import References from "./References";
|
||||
import { type LocationWithState } from "types";
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
documentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
let EditorImport;
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
const IS_DIRTY_DELAY = 500;
|
||||
const DISCARD_CHANGES = `
|
||||
@@ -68,7 +68,8 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class DocumentScene extends React.Component<Props> {
|
||||
@observable editor = React.createRef();
|
||||
@observable editor: ?any;
|
||||
@observable editorComponent = EditorImport;
|
||||
@observable isUploading: boolean = false;
|
||||
@observable isSaving: boolean = false;
|
||||
@observable isPublishing: boolean = false;
|
||||
@@ -83,6 +84,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
super();
|
||||
this.title = props.document.title;
|
||||
this.lastRevision = props.document.revision;
|
||||
this.loadEditor();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@@ -134,7 +136,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
ev.preventDefault();
|
||||
const { document, abilities } = this.props;
|
||||
|
||||
if (abilities.move) {
|
||||
if (abilities.update) {
|
||||
this.props.history.push(documentMoveUrl(document));
|
||||
}
|
||||
}
|
||||
@@ -195,6 +197,25 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
loadEditor = async () => {
|
||||
if (this.editorComponent) return;
|
||||
|
||||
try {
|
||||
const EditorImport = await import("./Editor");
|
||||
this.editorComponent = EditorImport.default;
|
||||
} catch (err) {
|
||||
if (err.message && err.message.match(/chunk/)) {
|
||||
// If the editor bundle fails to load then reload the entire window. This
|
||||
// can happen if a deploy happens between the user loading the initial JS
|
||||
// bundle and the async-loaded editor JS bundle as the hash will change.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
handleCloseMoveModal = () => (this.moveModalOpen = false);
|
||||
handleOpenMoveModal = () => (this.moveModalOpen = true);
|
||||
|
||||
@@ -317,14 +338,20 @@ class DocumentScene extends React.Component<Props> {
|
||||
document,
|
||||
revision,
|
||||
readOnly,
|
||||
location,
|
||||
abilities,
|
||||
auth,
|
||||
ui,
|
||||
match,
|
||||
} = this.props;
|
||||
const team = auth.team;
|
||||
const Editor = this.editorComponent;
|
||||
const isShare = !!match.params.shareId;
|
||||
|
||||
if (!Editor) {
|
||||
return <Loading location={location} />;
|
||||
}
|
||||
|
||||
const value = revision ? revision.text : document.text;
|
||||
const injectTemplate = document.injectTemplate;
|
||||
const disableEmbeds =
|
||||
@@ -381,7 +408,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
tocVisible={ui.tocVisible && readOnly}
|
||||
tocVisible={ui.tocVisible}
|
||||
column
|
||||
auto
|
||||
>
|
||||
@@ -413,52 +440,50 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<React.Suspense fallback={<LoadingPlaceholder />}>
|
||||
<Flex auto={!readOnly}>
|
||||
{ui.tocVisible && readOnly && (
|
||||
<Contents
|
||||
headings={
|
||||
this.editor.current
|
||||
? this.editor.current.getHeadings()
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Editor
|
||||
id={document.id}
|
||||
innerRef={this.editor}
|
||||
isShare={isShare}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
key={[injectTemplate, disableEmbeds].join("-")}
|
||||
title={revision ? revision.title : this.title}
|
||||
document={document}
|
||||
value={readOnly ? value : undefined}
|
||||
defaultValue={value}
|
||||
disableEmbeds={disableEmbeds}
|
||||
onImageUploadStart={this.onImageUploadStart}
|
||||
onImageUploadStop={this.onImageUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
ui={this.props.ui}
|
||||
<Flex auto={!readOnly}>
|
||||
{ui.tocVisible && readOnly && (
|
||||
<Contents
|
||||
headings={this.editor ? this.editor.getHeadings() : []}
|
||||
/>
|
||||
</Flex>
|
||||
{readOnly && !isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
<Editor
|
||||
id={document.id}
|
||||
ref={(ref) => {
|
||||
if (ref) {
|
||||
this.editor = ref;
|
||||
}
|
||||
}}
|
||||
isShare={isShare}
|
||||
isDraft={document.isDraft}
|
||||
template={document.isTemplate}
|
||||
key={[injectTemplate, disableEmbeds].join("-")}
|
||||
title={revision ? revision.title : this.title}
|
||||
document={document}
|
||||
value={readOnly ? value : undefined}
|
||||
defaultValue={value}
|
||||
disableEmbeds={disableEmbeds}
|
||||
onImageUploadStart={this.onImageUploadStart}
|
||||
onImageUploadStop={this.onImageUploadStop}
|
||||
onSearchLink={this.props.onSearchLink}
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnly && abilities.update}
|
||||
ui={this.props.ui}
|
||||
/>
|
||||
</Flex>
|
||||
{readOnly && !isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
</MaxWidth>
|
||||
</Container>
|
||||
</Background>
|
||||
|
||||
@@ -13,11 +13,13 @@ import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
import Flex from "components/Flex";
|
||||
import { Outline } from "components/Input";
|
||||
import Input from "components/Input";
|
||||
import Labeled from "components/Labeled";
|
||||
import Modal from "components/Modal";
|
||||
import PathToDocument from "components/PathToDocument";
|
||||
|
||||
const MAX_RESULTS = 8;
|
||||
|
||||
type Props = {|
|
||||
document: Document,
|
||||
documents: DocumentsStore,
|
||||
@@ -34,19 +36,14 @@ class DocumentMove extends React.Component<Props> {
|
||||
|
||||
@computed
|
||||
get searchIndex() {
|
||||
const { collections, documents } = this.props;
|
||||
const { collections } = this.props;
|
||||
const paths = collections.pathsToDocuments;
|
||||
const index = new Search("id");
|
||||
index.addIndex("title");
|
||||
|
||||
// Build index
|
||||
const indexeableDocuments = [];
|
||||
paths.forEach((path) => {
|
||||
const doc = documents.get(path.id);
|
||||
if (!doc || !doc.isTemplate) {
|
||||
indexeableDocuments.push(path);
|
||||
}
|
||||
});
|
||||
paths.forEach((path) => indexeableDocuments.push(path));
|
||||
index.addDocuments(indexeableDocuments);
|
||||
|
||||
return index;
|
||||
@@ -55,7 +52,6 @@ class DocumentMove extends React.Component<Props> {
|
||||
@computed
|
||||
get results(): DocumentPath[] {
|
||||
const { document, collections } = this.props;
|
||||
const onlyShowCollections = document.isTemplate;
|
||||
|
||||
let results = [];
|
||||
if (collections.isLoaded) {
|
||||
@@ -66,24 +62,18 @@ class DocumentMove extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
if (onlyShowCollections) {
|
||||
results = results.filter((result) => result.type === "collection");
|
||||
} else {
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter(
|
||||
(result) => result.id !== document.collectionId
|
||||
);
|
||||
}
|
||||
|
||||
// Exclude document if on the path to result, or the same result
|
||||
results = results.filter(
|
||||
(result) =>
|
||||
!result.path.map((doc) => doc.id).includes(document.id) &&
|
||||
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
|
||||
);
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter((result) => result.id !== document.collectionId);
|
||||
}
|
||||
|
||||
// Exclude document if on the path to result, or the same result
|
||||
results = results.filter(
|
||||
(result) =>
|
||||
!result.path.map((doc) => doc.id).includes(document.id) &&
|
||||
last(result.path.map((doc) => doc.id)) !== document.parentDocumentId
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -139,41 +129,35 @@ class DocumentMove extends React.Component<Props> {
|
||||
</Section>
|
||||
|
||||
<Section column>
|
||||
<Labeled label="Choose a new location" />
|
||||
<NewLocation>
|
||||
<InputWrapper>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search collections & documents…"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputWrapper>
|
||||
|
||||
<Results>
|
||||
<Flex column>
|
||||
<StyledArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.results.map((result, index) => (
|
||||
<PathToDocument
|
||||
key={result.id}
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
ref={(ref) =>
|
||||
index === 0 && this.setFirstDocumentRef(ref)
|
||||
}
|
||||
onSuccess={this.handleSuccess}
|
||||
/>
|
||||
))}
|
||||
</StyledArrowKeyNavigation>
|
||||
</Flex>
|
||||
</Results>
|
||||
</NewLocation>
|
||||
<Labeled label="Choose a new location">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search collections & documents…"
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onChange={this.handleFilter}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</Labeled>
|
||||
<Flex column>
|
||||
<StyledArrowKeyNavigation
|
||||
mode={ArrowKeyNavigation.mode.VERTICAL}
|
||||
defaultActiveChildIndex={0}
|
||||
>
|
||||
{this.results.slice(0, MAX_RESULTS).map((result, index) => (
|
||||
<PathToDocument
|
||||
key={result.id}
|
||||
result={result}
|
||||
document={document}
|
||||
collection={collections.get(result.collectionId)}
|
||||
ref={(ref) =>
|
||||
index === 0 && this.setFirstDocumentRef(ref)
|
||||
}
|
||||
onSuccess={this.handleSuccess}
|
||||
/>
|
||||
))}
|
||||
</StyledArrowKeyNavigation>
|
||||
</Flex>
|
||||
</Section>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -182,37 +166,6 @@ class DocumentMove extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
const InputWrapper = styled("div")`
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Input = styled("input")`
|
||||
width: 100%;
|
||||
outline: none;
|
||||
background: none;
|
||||
border-radius: 4px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
const NewLocation = styled(Outline)`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Results = styled(Flex)`
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
`;
|
||||
|
||||
const Section = styled(Flex)`
|
||||
margin-bottom: 24px;
|
||||
`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import Textarea from "react-autosize-textarea";
|
||||
import RichMarkdownEditor from "rich-markdown-editor";
|
||||
import styled from "styled-components";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
import Document from "models/Document";
|
||||
@@ -21,25 +22,33 @@ type Props = {
|
||||
isDraft: boolean,
|
||||
isShare: boolean,
|
||||
readOnly?: boolean,
|
||||
innerRef: { current: any },
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
editor = React.createRef<RichMarkdownEditor>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
this.props.innerRef.current.focusAtStart();
|
||||
if (this.editor.current) {
|
||||
this.editor.current.focusAtStart();
|
||||
}
|
||||
};
|
||||
|
||||
focusAtEnd = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
this.props.innerRef.current.focusAtEnd();
|
||||
if (this.editor.current) {
|
||||
this.editor.current.focusAtEnd();
|
||||
}
|
||||
};
|
||||
|
||||
getHeadings = () => {
|
||||
if (this.editor.current) {
|
||||
return this.editor.current.getHeadings();
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
@@ -63,7 +72,6 @@ class DocumentEditor extends React.Component<Props> {
|
||||
isDraft,
|
||||
isShare,
|
||||
readOnly,
|
||||
innerRef,
|
||||
} = this.props;
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
@@ -87,7 +95,7 @@ class DocumentEditor extends React.Component<Props> {
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
ref={this.editor}
|
||||
autoFocus={title && !this.props.defaultValue}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
}
|
||||
|
||||
setupOnce = () => {
|
||||
if (this.context && this.context !== this.previousContext) {
|
||||
if (this.context && !this.previousContext) {
|
||||
this.previousContext = this.context;
|
||||
|
||||
if (this.context.authenticated) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import Document from "models/Document";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import { collectionUrl, documentUrl } from "utils/routeHelpers";
|
||||
import { collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
@@ -24,27 +24,15 @@ class DocumentDelete extends React.Component<Props> {
|
||||
@observable isDeleting: boolean;
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
const { documents, document } = this.props;
|
||||
ev.preventDefault();
|
||||
this.isDeleting = true;
|
||||
|
||||
try {
|
||||
await document.delete();
|
||||
|
||||
// only redirect if we're currently viewing the document that's deleted
|
||||
if (this.props.ui.activeDocumentId === document.id) {
|
||||
// If the document has a parent and it's available in the store then
|
||||
// redirect to it
|
||||
if (document.parentDocumentId) {
|
||||
const parent = documents.get(document.parentDocumentId);
|
||||
if (parent) {
|
||||
this.props.history.push(documentUrl(parent));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, redirect to the collection home
|
||||
this.props.history.push(collectionUrl(document.collectionId));
|
||||
await this.props.document.delete();
|
||||
if (this.props.ui.activeDocumentId === this.props.document.id) {
|
||||
this.props.history.push(
|
||||
collectionUrl(this.props.document.collectionId)
|
||||
);
|
||||
}
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -82,7 +82,7 @@ class Login extends React.Component<Props, State> {
|
||||
<PageTitle title="Check your email" />
|
||||
<CheckEmailIcon size={38} color="currentColor" />
|
||||
|
||||
<Heading centered>Check your email</Heading>
|
||||
<Heading>Check your email</Heading>
|
||||
<Note>
|
||||
A magic sign-in link has been sent to the email{" "}
|
||||
<em>{this.state.emailLinkSentTo}</em>, no password needed.
|
||||
@@ -110,9 +110,9 @@ class Login extends React.Component<Props, State> {
|
||||
</Logo>
|
||||
|
||||
{isCreate ? (
|
||||
<Heading centered>Create an account</Heading>
|
||||
<Heading>Create an account</Heading>
|
||||
) : (
|
||||
<Heading centered>Login to {config.name || "Outline"}</Heading>
|
||||
<Heading>Login to {config.name || "Outline"}</Heading>
|
||||
)}
|
||||
|
||||
<Notices notice={getQueryVariable("notice")} />
|
||||
|
||||
@@ -12,7 +12,6 @@ import { withRouter, Link } from "react-router-dom";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import { Waypoint } from "react-waypoint";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
|
||||
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
@@ -388,12 +387,6 @@ const Filters = styled(Flex)`
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.85;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: 0;
|
||||
`};
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
+21
-23
@@ -32,18 +32,16 @@ class UiStore {
|
||||
}
|
||||
|
||||
// system theme listeners
|
||||
if (window.matchMedia) {
|
||||
const colorSchemeQueryList = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
);
|
||||
const colorSchemeQueryList = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
);
|
||||
|
||||
const setSystemTheme = (event) => {
|
||||
this.systemTheme = event.matches ? "dark" : "light";
|
||||
};
|
||||
setSystemTheme(colorSchemeQueryList);
|
||||
if (colorSchemeQueryList.addListener) {
|
||||
colorSchemeQueryList.addListener(setSystemTheme);
|
||||
}
|
||||
const setSystemTheme = (event) => {
|
||||
this.systemTheme = event.matches ? "dark" : "light";
|
||||
};
|
||||
setSystemTheme(colorSchemeQueryList);
|
||||
if (colorSchemeQueryList.addListener) {
|
||||
colorSchemeQueryList.addListener(setSystemTheme);
|
||||
}
|
||||
|
||||
// persisted keys
|
||||
@@ -109,34 +107,34 @@ class UiStore {
|
||||
};
|
||||
|
||||
@action
|
||||
enableEditMode = () => {
|
||||
enableEditMode() {
|
||||
this.editMode = true;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
disableEditMode = () => {
|
||||
disableEditMode() {
|
||||
this.editMode = false;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
enableProgressBar = () => {
|
||||
enableProgressBar() {
|
||||
this.progressBarVisible = true;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
disableProgressBar = () => {
|
||||
disableProgressBar() {
|
||||
this.progressBarVisible = false;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
toggleMobileSidebar = () => {
|
||||
toggleMobileSidebar() {
|
||||
this.mobileSidebarVisible = !this.mobileSidebarVisible;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
hideMobileSidebar = () => {
|
||||
hideMobileSidebar() {
|
||||
this.mobileSidebarVisible = false;
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
showToast = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import invariant from "invariant";
|
||||
import { map, trim } from "lodash";
|
||||
import pkg from "rich-markdown-editor/package.json";
|
||||
import stores from "stores";
|
||||
import download from "./download";
|
||||
import {
|
||||
@@ -56,7 +57,7 @@ class ApiClient {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"cache-control": "no-cache",
|
||||
"x-editor-version": EDITOR_VERSION,
|
||||
"x-editor-version": pkg.version,
|
||||
pragma: "no-cache",
|
||||
});
|
||||
if (stores.auth.authenticated) {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// @flow
|
||||
let hidden = "hidden";
|
||||
let visibilityChange = "visibilitychange";
|
||||
|
||||
if ("hidden" in document) {
|
||||
hidden = "hidden";
|
||||
visibilityChange = "visibilitychange";
|
||||
} else if ("mozHidden" in document) {
|
||||
// Firefox up to v17
|
||||
hidden = "mozHidden";
|
||||
visibilityChange = "mozvisibilitychange";
|
||||
} else if ("webkitHidden" in document) {
|
||||
// Chrome up to v32, Android up to v4.4, Blackberry up to v10
|
||||
hidden = "webkitHidden";
|
||||
visibilityChange = "webkitvisibilitychange";
|
||||
}
|
||||
|
||||
export function getVisibilityListener(): string {
|
||||
return visibilityChange;
|
||||
}
|
||||
|
||||
export function getPageVisible(): boolean {
|
||||
// $FlowFixMe
|
||||
return !document[hidden];
|
||||
}
|
||||
Vendored
-2
@@ -4,5 +4,3 @@ declare var process: {
|
||||
[string]: string,
|
||||
},
|
||||
};
|
||||
|
||||
declare var EDITOR_VERSION: string;
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// flow-typed signature: d828559e8abc3863ee0f8ef88f5b646e
|
||||
// flow-typed version: <<STUB>>/identity-obj-proxy_v^3.0.0/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'identity-obj-proxy'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'identity-obj-proxy' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-export-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-export-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-vanilla-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/index-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-export-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-export-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-vanilla-test' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Export' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Import' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6ImportExport' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-export-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-export-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-export-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-import-export-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-es6-import-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-es6-import-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/import-vanilla-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/import-vanilla-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/index-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/index-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-export-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-export-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-export-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-import-export-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-es6-import-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-es6-import-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/__tests__/require-vanilla-test.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/__tests__/require-vanilla-test'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/index' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/index.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Export.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6Export'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6Import.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6Import'>;
|
||||
}
|
||||
declare module 'identity-obj-proxy/src/test-redirections/idObjES6ImportExport.js' {
|
||||
declare module.exports: $Exports<'identity-obj-proxy/src/test-redirections/idObjES6ImportExport'>;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// flow-typed signature: 90824cfc39ff764d3f06f9f71bdb6ef1
|
||||
// flow-typed version: <<STUB>>/uglifyjs-webpack-plugin_v1.2.5/flow_v0.104.0
|
||||
|
||||
/**
|
||||
* This is an autogenerated libdef stub for:
|
||||
*
|
||||
* 'uglifyjs-webpack-plugin'
|
||||
*
|
||||
* Fill this stub out by replacing all the `any` types.
|
||||
*
|
||||
* Once filled out, we encourage you to share your work with the
|
||||
* community by sending a pull request to:
|
||||
* https://github.com/flowtype/flow-typed
|
||||
*/
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* We include stubs for each file inside this npm package in case you need to
|
||||
* require those files directly. Feel free to delete any files that aren't
|
||||
* needed.
|
||||
*/
|
||||
declare module 'uglifyjs-webpack-plugin/dist/cjs' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/minify' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/versions' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/worker' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
declare module 'uglifyjs-webpack-plugin/dist/utils' {
|
||||
declare module.exports: any;
|
||||
}
|
||||
|
||||
// Filename aliases
|
||||
declare module 'uglifyjs-webpack-plugin/dist/cjs.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/cjs'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/index' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/index.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/index' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/index.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/minify.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/minify'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/versions.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/versions'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/uglify/worker.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/uglify/worker'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/utils/index' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/utils'>;
|
||||
}
|
||||
declare module 'uglifyjs-webpack-plugin/dist/utils/index.js' {
|
||||
declare module.exports: $Exports<'uglifyjs-webpack-plugin/dist/utils'>;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// @flow
|
||||
require("./init");
|
||||
|
||||
if (
|
||||
!process.env.SECRET_KEY ||
|
||||
process.env.SECRET_KEY === "generate_a_new_key"
|
||||
) {
|
||||
console.error(
|
||||
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.AWS_ACCESS_KEY_ID) {
|
||||
[
|
||||
"AWS_REGION",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_S3_UPLOAD_BUCKET_URL",
|
||||
"AWS_S3_UPLOAD_BUCKET_NAME",
|
||||
"AWS_S3_UPLOAD_MAX_SIZE",
|
||||
].forEach(key => {
|
||||
if (!process.env[key]) {
|
||||
console.error(`The ${key} env variable must be set when using AWS`);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.SLACK_KEY) {
|
||||
if (!process.env.SLACK_SECRET) {
|
||||
console.error(
|
||||
`The SLACK_SECRET env variable must be set when using Slack Sign In`
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.URL) {
|
||||
console.error(
|
||||
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error(
|
||||
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.REDIS_URL) {
|
||||
console.error(
|
||||
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.log("\n\x1b[33m%s\x1b[0m", "Running Outline in production mode.");
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
console.log(
|
||||
"\n\x1b[33m%s\x1b[0m",
|
||||
'Running Outline in development mode with hot reloading. To run Outline in production mode set the NODE_ENV env variable to "production"'
|
||||
);
|
||||
}
|
||||
|
||||
require("./server");
|
||||
@@ -0,0 +1,4 @@
|
||||
// @flow
|
||||
require("@babel/register");
|
||||
require("@babel/polyfill");
|
||||
require("dotenv").config({ silent: true });
|
||||
+39
-41
@@ -1,22 +1,21 @@
|
||||
{
|
||||
"name": "outline",
|
||||
"private": true,
|
||||
"license": "Business Source License 1.1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"clean": "rimraf build",
|
||||
"build:server": "babel -d ./build/server ./server && babel -d ./build/shared ./shared && cp package.json ./build && ln -sf \"$(pwd)/webpack.config.dev.js\" ./build",
|
||||
"build:webpack": "webpack --config webpack.config.prod.js",
|
||||
"build": "yarn clean && yarn build:webpack && yarn build:server",
|
||||
"start": "node ./build/server/index.js",
|
||||
"dev": "nodemon --exec \"yarn build:server && node build/server/index.js\" -e js --ignore build/",
|
||||
"clean": "rimraf dist",
|
||||
"build:webpack": "NODE_ENV=production webpack --config webpack.config.prod.js",
|
||||
"build:analyze": "NODE_ENV=production webpack --config webpack.config.prod.js --json > stats.json",
|
||||
"build": "npm run clean && npm run build:webpack",
|
||||
"start": "NODE_ENV=production node index.js",
|
||||
"dev": "NODE_ENV=development nodemon --watch server index.js",
|
||||
"lint": "eslint app server shared",
|
||||
"flow": "flow",
|
||||
"deploy": "git push heroku master",
|
||||
"heroku-postbuild": "yarn build && yarn sequelize:migrate",
|
||||
"heroku-postbuild": "npm run build && npm run sequelize:migrate",
|
||||
"sequelize:create-migration": "sequelize migration:create",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"test": "yarn test:app && yarn test:server",
|
||||
"test": "npm run test:app && npm run test:server",
|
||||
"test:app": "jest",
|
||||
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
||||
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
|
||||
@@ -49,7 +48,7 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12 <15"
|
||||
"node": ">= 12 <14"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -57,23 +56,14 @@
|
||||
},
|
||||
"browserslist": "> 0.25%, not dead",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.11.1",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||
"@babel/plugin-transform-destructuring": "^7.10.4",
|
||||
"@babel/plugin-transform-regenerator": "^7.10.4",
|
||||
"@babel/polyfill": "^7.10.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-flow": "^7.10.4",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"@babel/register": "^7.10.5",
|
||||
"@rehooks/window-scroll-position": "^1.0.1",
|
||||
"@sentry/node": "^5.22.3",
|
||||
"@sentry/node": "^5.12.2",
|
||||
"@tippy.js/react": "^2.2.2",
|
||||
"@tommoor/remove-markdown": "0.3.1",
|
||||
"autotrack": "^2.4.1",
|
||||
"aws-sdk": "^2.135.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"boundless-arrow-key-navigation": "^1.0.4",
|
||||
"boundless-popover": "^1.0.4",
|
||||
"bull": "^3.5.2",
|
||||
@@ -90,6 +80,7 @@
|
||||
"flow-typed": "^2.6.2",
|
||||
"fs-extra": "^4.0.2",
|
||||
"google-auth-library": "^5.5.1",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"http-errors": "1.4.0",
|
||||
"immutable": "^3.8.2",
|
||||
"imports-loader": "0.6.5",
|
||||
@@ -115,13 +106,13 @@
|
||||
"koa-static": "^4.0.1",
|
||||
"lodash": "^4.17.19",
|
||||
"mobx": "4.6.0",
|
||||
"mobx-react": "^6.2.5",
|
||||
"mobx-react": "^5.4.2",
|
||||
"natural-sort": "^1.0.0",
|
||||
"nodemailer": "^4.4.0",
|
||||
"outline-icons": "^1.21.0-6",
|
||||
"oy-vey": "^0.10.0",
|
||||
"pg": "^8.3.0",
|
||||
"pg-hstore": "^2.3.3",
|
||||
"pg": "^6.1.5",
|
||||
"pg-hstore": "2.3.2",
|
||||
"polished": "3.6.5",
|
||||
"query-string": "^4.3.4",
|
||||
"randomstring": "1.1.5",
|
||||
@@ -138,11 +129,11 @@
|
||||
"react-portal": "^4.0.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-waypoint": "^9.0.2",
|
||||
"rich-markdown-editor": "^10.6.5",
|
||||
"rich-markdown-editor": "^10.5.0",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.3.4",
|
||||
"sequelize-cli": "^6.2.0",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize": "^5.21.1",
|
||||
"sequelize-cli": "^5.5.0",
|
||||
"sequelize-encrypted": "^0.1.0",
|
||||
"slate": "0.45.0",
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^1.0.0",
|
||||
@@ -155,15 +146,27 @@
|
||||
"styled-normalize": "^8.0.4",
|
||||
"tiny-cookie": "^2.3.1",
|
||||
"tmp": "0.0.33",
|
||||
"uglifyjs-webpack-plugin": "1.2.5",
|
||||
"url-loader": "^0.6.2",
|
||||
"uuid": "2.0.2",
|
||||
"validator": "5.2.0"
|
||||
"validator": "5.2.0",
|
||||
"webpack": "3.10.0",
|
||||
"webpack-manifest-plugin": "^1.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.10.5",
|
||||
"@relative-ci/agent": "^1.3.0",
|
||||
"@babel/core": "^7.11.1",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||
"@babel/plugin-transform-destructuring": "^7.10.4",
|
||||
"@babel/plugin-transform-regenerator": "^7.10.4",
|
||||
"@babel/preset-env": "^7.11.0",
|
||||
"@babel/preset-flow": "^7.10.4",
|
||||
"@babel/preset-react": "^7.10.4",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^26.2.2",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-config-react-app": "3.0.6",
|
||||
"eslint-plugin-flowtype": "^5.2.0",
|
||||
@@ -171,25 +174,20 @@
|
||||
"eslint-plugin-jsx-a11y": "^6.1.0",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-react": "^7.20.0",
|
||||
"eslint-plugin-react-hooks": "^4.1.0",
|
||||
"fetch-test-server": "^1.1.0",
|
||||
"flow-bin": "^0.104.0",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest-cli": "^26.0.0",
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
"mobx-react-devtools": "^6.0.3",
|
||||
"nodemon": "^1.19.4",
|
||||
"prettier": "^2.0.5",
|
||||
"rimraf": "^2.5.4",
|
||||
"terser-webpack-plugin": "^4.1.0",
|
||||
"url-loader": "^0.6.2",
|
||||
"webpack": "4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^2.2.0"
|
||||
"rimraf": "^2.5.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.13.1"
|
||||
},
|
||||
"version": "0.46.1"
|
||||
}
|
||||
"version": "0.45.0"
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const { authorize } = policy;
|
||||
const router = new Router();
|
||||
|
||||
router.post("collections.create", auth(), async (ctx) => {
|
||||
const { name, color, description, icon } = ctx.body;
|
||||
const { name, color, description, icon, type } = ctx.body;
|
||||
const isPrivate = ctx.body.private;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
@@ -46,6 +46,7 @@ router.post("collections.create", auth(), async (ctx) => {
|
||||
description,
|
||||
icon,
|
||||
color,
|
||||
type: type || "atlas",
|
||||
teamId: user.teamId,
|
||||
creatorId: user.id,
|
||||
private: isPrivate,
|
||||
|
||||
@@ -1062,6 +1062,7 @@ describe("#collections.delete", () => {
|
||||
urlId: "blah",
|
||||
teamId: user.teamId,
|
||||
creatorId: user.id,
|
||||
type: "atlas",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/collections.delete", {
|
||||
|
||||
@@ -718,7 +718,7 @@ router.post("documents.create", auth(), async (ctx) => {
|
||||
authorize(user, "publish", collection);
|
||||
|
||||
let parentDocument;
|
||||
if (parentDocumentId) {
|
||||
if (parentDocumentId && collection.type === "atlas") {
|
||||
parentDocument = await Document.findOne({
|
||||
where: {
|
||||
id: parentDocumentId,
|
||||
@@ -938,6 +938,13 @@ router.post("documents.move", auth(), async (ctx) => {
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "move", document);
|
||||
|
||||
const { collection } = document;
|
||||
if (collection.type !== "atlas" && parentDocumentId) {
|
||||
throw new InvalidRequestError(
|
||||
"Document cannot be nested in this collection type"
|
||||
);
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
const parent = await Document.findByPk(parentDocumentId, {
|
||||
userId: user.id,
|
||||
@@ -1001,7 +1008,7 @@ router.post("documents.delete", auth(), async (ctx) => {
|
||||
const document = await Document.findByPk(id, { userId: user.id });
|
||||
authorize(user, "delete", document);
|
||||
|
||||
await document.delete(user.id);
|
||||
await document.delete();
|
||||
|
||||
await Event.create({
|
||||
name: "documents.delete",
|
||||
|
||||
@@ -22,96 +22,79 @@ export default async function documentMover({
|
||||
const result = { collections: [], documents: [] };
|
||||
const collectionChanged = collectionId !== document.collectionId;
|
||||
|
||||
if (document.template) {
|
||||
if (!collectionChanged) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
|
||||
// remove from original collection
|
||||
const collection = await document.getCollection({ transaction });
|
||||
const documentJson = await collection.removeDocumentInStructure(document, {
|
||||
save: false,
|
||||
});
|
||||
|
||||
// if the collection is the same then it will get saved below, this
|
||||
// line prevents a pointless intermediate save from occurring.
|
||||
if (collectionChanged) await collection.save({ transaction });
|
||||
|
||||
// add to new collection (may be the same)
|
||||
document.collectionId = collectionId;
|
||||
document.parentDocumentId = null;
|
||||
document.parentDocumentId = parentDocumentId;
|
||||
|
||||
await document.save();
|
||||
result.documents.push(document);
|
||||
} else {
|
||||
try {
|
||||
transaction = await sequelize.transaction();
|
||||
const newCollection: Collection = collectionChanged
|
||||
? await Collection.findByPk(collectionId, { transaction })
|
||||
: collection;
|
||||
await newCollection.addDocumentToStructure(document, index, {
|
||||
documentJson,
|
||||
});
|
||||
result.collections.push(collection);
|
||||
|
||||
// remove from original collection
|
||||
const collection = await Collection.findByPk(document.collectionId, {
|
||||
transaction,
|
||||
});
|
||||
const documentJson = await collection.removeDocumentInStructure(
|
||||
document,
|
||||
{
|
||||
save: false,
|
||||
}
|
||||
);
|
||||
// if collection does not remain the same loop through children and change their
|
||||
// collectionId too. This includes archived children, otherwise their collection
|
||||
// would be wrong once restored.
|
||||
if (collectionChanged) {
|
||||
result.collections.push(newCollection);
|
||||
|
||||
// if the collection is the same then it will get saved below, this
|
||||
// line prevents a pointless intermediate save from occurring.
|
||||
if (collectionChanged) await collection.save({ transaction });
|
||||
const loopChildren = async (documentId) => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: { parentDocumentId: documentId },
|
||||
});
|
||||
|
||||
// add to new collection (may be the same)
|
||||
document.collectionId = collectionId;
|
||||
document.parentDocumentId = parentDocumentId;
|
||||
await Promise.all(
|
||||
childDocuments.map(async (child) => {
|
||||
await loopChildren(child.id);
|
||||
await child.update({ collectionId }, { transaction });
|
||||
child.collection = newCollection;
|
||||
result.documents.push(child);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const newCollection: Collection = collectionChanged
|
||||
? await Collection.findByPk(collectionId, { transaction })
|
||||
: collection;
|
||||
await newCollection.addDocumentToStructure(document, index, {
|
||||
documentJson,
|
||||
});
|
||||
result.collections.push(collection);
|
||||
|
||||
// if collection does not remain the same loop through children and change their
|
||||
// collectionId too. This includes archived children, otherwise their collection
|
||||
// would be wrong once restored.
|
||||
if (collectionChanged) {
|
||||
result.collections.push(newCollection);
|
||||
|
||||
const loopChildren = async (documentId) => {
|
||||
const childDocuments = await Document.findAll({
|
||||
where: { parentDocumentId: documentId },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
childDocuments.map(async (child) => {
|
||||
await loopChildren(child.id);
|
||||
await child.update({ collectionId }, { transaction });
|
||||
child.collection = newCollection;
|
||||
result.documents.push(child);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
await loopChildren(document.id);
|
||||
}
|
||||
|
||||
await document.save({ transaction });
|
||||
result.documents.push(document);
|
||||
|
||||
await transaction.commit();
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
await loopChildren(document.id);
|
||||
}
|
||||
}
|
||||
|
||||
await Event.create({
|
||||
name: "documents.move",
|
||||
actorId: user.id,
|
||||
documentId: document.id,
|
||||
collectionId,
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
collectionIds: result.collections.map((c) => c.id),
|
||||
documentIds: result.documents.map((d) => d.id),
|
||||
},
|
||||
ip,
|
||||
});
|
||||
await document.save({ transaction });
|
||||
result.documents.push(document);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
await Event.create({
|
||||
name: "documents.move",
|
||||
actorId: user.id,
|
||||
documentId: document.id,
|
||||
collectionId,
|
||||
teamId: document.teamId,
|
||||
data: {
|
||||
title: document.title,
|
||||
collectionIds: result.collections.map((c) => c.id),
|
||||
documentIds: result.documents.map((d) => d.id),
|
||||
},
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
if (transaction) {
|
||||
await transaction.rollback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// we need to send all updated models back to the client
|
||||
return result;
|
||||
|
||||
@@ -9,11 +9,6 @@
|
||||
},
|
||||
"production": {
|
||||
"use_env_variable": "DATABASE_URL",
|
||||
"dialect": "postgres",
|
||||
"dialectOptions": {
|
||||
"ssl": {
|
||||
"rejectUnauthorized": false
|
||||
}
|
||||
}
|
||||
"dialect": "postgres"
|
||||
}
|
||||
}
|
||||
+178
-62
@@ -1,73 +1,189 @@
|
||||
// @flow
|
||||
require("dotenv").config({ silent: true });
|
||||
import http from "http";
|
||||
import IO from "socket.io";
|
||||
import socketRedisAdapter from "socket.io-redis";
|
||||
import SocketAuth from "socketio-auth";
|
||||
import app from "./app";
|
||||
import { Document, Collection, View } from "./models";
|
||||
import policy from "./policies";
|
||||
import { client, subscriber } from "./redis";
|
||||
import { getUserForJWT } from "./utils/jwt";
|
||||
|
||||
if (
|
||||
!process.env.SECRET_KEY ||
|
||||
process.env.SECRET_KEY === "generate_a_new_key"
|
||||
) {
|
||||
console.error(
|
||||
"The SECRET_KEY env variable must be set with the output of `openssl rand -hex 32`"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
const server = http.createServer(app.callback());
|
||||
let io;
|
||||
|
||||
if (process.env.AWS_ACCESS_KEY_ID) {
|
||||
[
|
||||
"AWS_REGION",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"AWS_S3_UPLOAD_BUCKET_URL",
|
||||
"AWS_S3_UPLOAD_MAX_SIZE",
|
||||
].forEach((key) => {
|
||||
if (!process.env[key]) {
|
||||
console.error(`The ${key} env variable must be set when using AWS`);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
const { can } = policy;
|
||||
|
||||
io = IO(server, {
|
||||
path: "/realtime",
|
||||
serveClient: false,
|
||||
cookie: false,
|
||||
});
|
||||
|
||||
io.adapter(
|
||||
socketRedisAdapter({
|
||||
pubClient: client,
|
||||
subClient: subscriber,
|
||||
})
|
||||
);
|
||||
|
||||
SocketAuth(io, {
|
||||
authenticate: async (socket, data, callback) => {
|
||||
const { token } = data;
|
||||
|
||||
try {
|
||||
const user = await getUserForJWT(token);
|
||||
socket.client.user = user;
|
||||
|
||||
// store the mapping between socket id and user id in redis
|
||||
// so that it is accessible across multiple server nodes
|
||||
await client.hset(socket.id, "userId", user.id);
|
||||
|
||||
return callback(null, true);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
postAuthenticate: async (socket, data) => {
|
||||
const { user } = socket.client;
|
||||
|
||||
if (process.env.SLACK_KEY) {
|
||||
if (!process.env.SLACK_SECRET) {
|
||||
console.error(
|
||||
`The SLACK_SECRET env variable must be set when using Slack Sign In`
|
||||
// the rooms associated with the current team
|
||||
// and user so we can send authenticated events
|
||||
let rooms = [`team-${user.teamId}`, `user-${user.id}`];
|
||||
|
||||
// the rooms associated with collections this user
|
||||
// has access to on connection. New collection subscriptions
|
||||
// are managed from the client as needed through the 'join' event
|
||||
const collectionIds = await user.collectionIds();
|
||||
collectionIds.forEach((collectionId) =>
|
||||
rooms.push(`collection-${collectionId}`)
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.env.URL) {
|
||||
console.error(
|
||||
"The URL env variable must be set to the externally accessible URL, e.g (https://www.getoutline.com)"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
// join all of the rooms at once
|
||||
socket.join(rooms);
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error(
|
||||
"The DATABASE_URL env variable must be set to the location of your postgres server, including authentication and port"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
// allow the client to request to join rooms
|
||||
socket.on("join", async (event) => {
|
||||
// user is joining a collection channel, because their permissions have
|
||||
// changed, granting them access.
|
||||
if (event.collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (!process.env.REDIS_URL) {
|
||||
console.error(
|
||||
"The REDIS_URL env variable must be set to the location of your redis server, including authentication and port"
|
||||
);
|
||||
// $FlowFixMe
|
||||
process.exit(1);
|
||||
}
|
||||
if (can(user, "read", collection)) {
|
||||
socket.join(`collection-${event.collectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
console.log("\n\x1b[33m%s\x1b[0m", "Running Outline in production mode.");
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
console.log(
|
||||
"\n\x1b[33m%s\x1b[0m",
|
||||
'Running Outline in development mode with hot reloading. To run Outline in production mode set the NODE_ENV env variable to "production"'
|
||||
);
|
||||
}
|
||||
// user is joining a document channel, because they have navigated to
|
||||
// view a document.
|
||||
if (event.documentId) {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
require("./main");
|
||||
if (can(user, "read", document)) {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
await View.touch(event.documentId, user.id, event.isEditing);
|
||||
const editing = await View.findRecentlyEditingByDocument(
|
||||
event.documentId
|
||||
);
|
||||
|
||||
socket.join(room, () => {
|
||||
// let everyone else in the room know that a new user joined
|
||||
io.to(room).emit("user.join", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
|
||||
// let this user know who else is already present in the room
|
||||
io.in(room).clients(async (err, sockets) => {
|
||||
if (err) throw err;
|
||||
|
||||
// because a single user can have multiple socket connections we
|
||||
// need to make sure that only unique userIds are returned. A Map
|
||||
// makes this easy.
|
||||
let userIds = new Map();
|
||||
for (const socketId of sockets) {
|
||||
const userId = await client.hget(socketId, "userId");
|
||||
userIds.set(userId, userId);
|
||||
}
|
||||
socket.emit("document.presence", {
|
||||
documentId: event.documentId,
|
||||
userIds: Array.from(userIds.keys()),
|
||||
editingIds: editing.map((view) => view.userId),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// allow the client to request to leave rooms
|
||||
socket.on("leave", (event) => {
|
||||
if (event.collectionId) {
|
||||
socket.leave(`collection-${event.collectionId}`);
|
||||
}
|
||||
if (event.documentId) {
|
||||
const room = `document-${event.documentId}`;
|
||||
socket.leave(room, () => {
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnecting", () => {
|
||||
const rooms = Object.keys(socket.rooms);
|
||||
|
||||
rooms.forEach((room) => {
|
||||
if (room.startsWith("document-")) {
|
||||
const documentId = room.replace("document-", "");
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("presence", async (event) => {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
const view = await View.touch(
|
||||
event.documentId,
|
||||
user.id,
|
||||
event.isEditing
|
||||
);
|
||||
view.user = user;
|
||||
|
||||
io.to(room).emit("user.presence", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.on("listening", () => {
|
||||
const address = server.address();
|
||||
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
||||
});
|
||||
|
||||
server.listen(process.env.PORT || "3000");
|
||||
|
||||
export const socketio = io;
|
||||
|
||||
export default server;
|
||||
|
||||
-189
@@ -1,189 +0,0 @@
|
||||
// @flow
|
||||
import http from "http";
|
||||
import IO from "socket.io";
|
||||
import socketRedisAdapter from "socket.io-redis";
|
||||
import SocketAuth from "socketio-auth";
|
||||
import app from "./app";
|
||||
import { Document, Collection, View } from "./models";
|
||||
import policy from "./policies";
|
||||
import { client, subscriber } from "./redis";
|
||||
import { getUserForJWT } from "./utils/jwt";
|
||||
|
||||
const server = http.createServer(app.callback());
|
||||
let io;
|
||||
|
||||
const { can } = policy;
|
||||
|
||||
io = IO(server, {
|
||||
path: "/realtime",
|
||||
serveClient: false,
|
||||
cookie: false,
|
||||
});
|
||||
|
||||
io.adapter(
|
||||
socketRedisAdapter({
|
||||
pubClient: client,
|
||||
subClient: subscriber,
|
||||
})
|
||||
);
|
||||
|
||||
SocketAuth(io, {
|
||||
authenticate: async (socket, data, callback) => {
|
||||
const { token } = data;
|
||||
|
||||
try {
|
||||
const user = await getUserForJWT(token);
|
||||
socket.client.user = user;
|
||||
|
||||
// store the mapping between socket id and user id in redis
|
||||
// so that it is accessible across multiple server nodes
|
||||
await client.hset(socket.id, "userId", user.id);
|
||||
|
||||
return callback(null, true);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
},
|
||||
postAuthenticate: async (socket, data) => {
|
||||
const { user } = socket.client;
|
||||
|
||||
// the rooms associated with the current team
|
||||
// and user so we can send authenticated events
|
||||
let rooms = [`team-${user.teamId}`, `user-${user.id}`];
|
||||
|
||||
// the rooms associated with collections this user
|
||||
// has access to on connection. New collection subscriptions
|
||||
// are managed from the client as needed through the 'join' event
|
||||
const collectionIds = await user.collectionIds();
|
||||
collectionIds.forEach((collectionId) =>
|
||||
rooms.push(`collection-${collectionId}`)
|
||||
);
|
||||
|
||||
// join all of the rooms at once
|
||||
socket.join(rooms);
|
||||
|
||||
// allow the client to request to join rooms
|
||||
socket.on("join", async (event) => {
|
||||
// user is joining a collection channel, because their permissions have
|
||||
// changed, granting them access.
|
||||
if (event.collectionId) {
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(event.collectionId);
|
||||
|
||||
if (can(user, "read", collection)) {
|
||||
socket.join(`collection-${event.collectionId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// user is joining a document channel, because they have navigated to
|
||||
// view a document.
|
||||
if (event.documentId) {
|
||||
const document = await Document.findByPk(event.documentId, {
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (can(user, "read", document)) {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
await View.touch(event.documentId, user.id, event.isEditing);
|
||||
const editing = await View.findRecentlyEditingByDocument(
|
||||
event.documentId
|
||||
);
|
||||
|
||||
socket.join(room, () => {
|
||||
// let everyone else in the room know that a new user joined
|
||||
io.to(room).emit("user.join", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
|
||||
// let this user know who else is already present in the room
|
||||
io.in(room).clients(async (err, sockets) => {
|
||||
if (err) throw err;
|
||||
|
||||
// because a single user can have multiple socket connections we
|
||||
// need to make sure that only unique userIds are returned. A Map
|
||||
// makes this easy.
|
||||
let userIds = new Map();
|
||||
for (const socketId of sockets) {
|
||||
const userId = await client.hget(socketId, "userId");
|
||||
userIds.set(userId, userId);
|
||||
}
|
||||
socket.emit("document.presence", {
|
||||
documentId: event.documentId,
|
||||
userIds: Array.from(userIds.keys()),
|
||||
editingIds: editing.map((view) => view.userId),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// allow the client to request to leave rooms
|
||||
socket.on("leave", (event) => {
|
||||
if (event.collectionId) {
|
||||
socket.leave(`collection-${event.collectionId}`);
|
||||
}
|
||||
if (event.documentId) {
|
||||
const room = `document-${event.documentId}`;
|
||||
socket.leave(room, () => {
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnecting", () => {
|
||||
const rooms = Object.keys(socket.rooms);
|
||||
|
||||
rooms.forEach((room) => {
|
||||
if (room.startsWith("document-")) {
|
||||
const documentId = room.replace("document-", "");
|
||||
io.to(room).emit("user.leave", {
|
||||
userId: user.id,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("presence", async (event) => {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
const view = await View.touch(
|
||||
event.documentId,
|
||||
user.id,
|
||||
event.isEditing
|
||||
);
|
||||
view.user = user;
|
||||
|
||||
io.to(room).emit("user.presence", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.on("listening", () => {
|
||||
const address = server.address();
|
||||
console.log(`\n> Listening on http://localhost:${address.port}\n`);
|
||||
});
|
||||
|
||||
server.listen(process.env.PORT || "3000");
|
||||
|
||||
export const socketio = io;
|
||||
|
||||
export default server;
|
||||
@@ -1,14 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('collections', 'type');
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('collections', 'type', {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: "atlas"
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -9,7 +9,7 @@ const Authentication = sequelize.define("authentication", {
|
||||
},
|
||||
service: DataTypes.STRING,
|
||||
scopes: DataTypes.ARRAY(DataTypes.STRING),
|
||||
token: encryptedFields().vault("token"),
|
||||
token: encryptedFields.vault("token"),
|
||||
});
|
||||
|
||||
Authentication.associate = (models) => {
|
||||
|
||||
@@ -23,6 +23,12 @@ const Collection = sequelize.define(
|
||||
color: DataTypes.STRING,
|
||||
private: DataTypes.BOOLEAN,
|
||||
maintainerApprovalRequired: DataTypes.BOOLEAN,
|
||||
type: {
|
||||
type: DataTypes.STRING,
|
||||
validate: { isIn: [["atlas", "journal"]] },
|
||||
},
|
||||
|
||||
/* type: atlas */
|
||||
documentStructure: DataTypes.JSONB,
|
||||
},
|
||||
{
|
||||
@@ -250,13 +256,11 @@ Collection.prototype.addDocumentToStructure = async function (
|
||||
}
|
||||
|
||||
// Sequelize doesn't seem to set the value with splice on JSONB field
|
||||
// https://github.com/sequelize/sequelize/blob/e1446837196c07b8ff0c23359b958d68af40fd6d/src/model.js#L3937
|
||||
this.changed("documentStructure", true);
|
||||
this.documentStructure = this.documentStructure;
|
||||
|
||||
if (options.save !== false) {
|
||||
await this.save({
|
||||
...options,
|
||||
fields: ["documentStructure"],
|
||||
transaction,
|
||||
});
|
||||
if (transaction) {
|
||||
|
||||
+21
-23
@@ -441,7 +441,7 @@ Document.addHook("beforeSave", async (model) => {
|
||||
}
|
||||
|
||||
const collection = await Collection.findByPk(model.collectionId);
|
||||
if (!collection) {
|
||||
if (!collection || collection.type !== "atlas") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ Document.addHook("afterCreate", async (model) => {
|
||||
}
|
||||
|
||||
const collection = await Collection.findByPk(model.collectionId);
|
||||
if (!collection) {
|
||||
if (!collection || collection.type !== "atlas") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -548,6 +548,8 @@ Document.prototype.publish = async function (options) {
|
||||
if (this.publishedAt) return this.save(options);
|
||||
|
||||
const collection = await Collection.findByPk(this.collectionId);
|
||||
if (collection.type !== "atlas") return this.save(options);
|
||||
|
||||
await collection.addDocumentToStructure(this);
|
||||
|
||||
this.publishedAt = new Date();
|
||||
@@ -570,7 +572,7 @@ Document.prototype.archive = async function (userId) {
|
||||
};
|
||||
|
||||
// Restore an archived document back to being visible to the team
|
||||
Document.prototype.unarchive = async function (userId: string) {
|
||||
Document.prototype.unarchive = async function (userId) {
|
||||
const collection = await this.getCollection();
|
||||
|
||||
// check to see if the documents parent hasn't been archived also
|
||||
@@ -602,27 +604,23 @@ Document.prototype.unarchive = async function (userId: string) {
|
||||
};
|
||||
|
||||
// Delete a document, archived or otherwise.
|
||||
Document.prototype.delete = function (userId: string) {
|
||||
return sequelize.transaction(
|
||||
async (transaction: Transaction): Promise<Document> => {
|
||||
if (!this.archivedAt && !this.template) {
|
||||
// delete any children and remove from the document structure
|
||||
const collection = await this.getCollection();
|
||||
if (collection) await collection.deleteDocument(this, { transaction });
|
||||
}
|
||||
|
||||
await Revision.destroy({
|
||||
where: { documentId: this.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
this.lastModifiedById = userId;
|
||||
this.deletedAt = new Date();
|
||||
|
||||
await this.save({ transaction });
|
||||
return this;
|
||||
Document.prototype.delete = function (options) {
|
||||
return sequelize.transaction(async (transaction: Transaction): Promise<*> => {
|
||||
if (!this.archivedAt) {
|
||||
// delete any children and remove from the document structure
|
||||
const collection = await this.getCollection();
|
||||
if (collection) await collection.deleteDocument(this, { transaction });
|
||||
}
|
||||
);
|
||||
|
||||
await Revision.destroy({
|
||||
where: { documentId: this.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
await this.destroy({ transaction, ...options });
|
||||
|
||||
return this;
|
||||
});
|
||||
};
|
||||
|
||||
Document.prototype.getTimestamp = function () {
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import { Document } from "../models";
|
||||
import {
|
||||
buildDocument,
|
||||
buildCollection,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "../test/factories";
|
||||
import { buildDocument, buildCollection, buildTeam } from "../test/factories";
|
||||
import { flushdb } from "../test/support";
|
||||
|
||||
beforeEach(() => flushdb());
|
||||
@@ -197,16 +192,3 @@ describe("#searchForTeam", () => {
|
||||
expect(results.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#delete", () => {
|
||||
test("should soft delete and set last modified", async () => {
|
||||
let document = await buildDocument();
|
||||
let user = await buildUser();
|
||||
|
||||
await document.delete(user.id);
|
||||
|
||||
document = await Document.findByPk(document.id, { paranoid: false });
|
||||
expect(document.lastModifiedById).toBe(user.id);
|
||||
expect(document.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +134,7 @@ Team.prototype.provisionFirstCollection = async function (userId) {
|
||||
name: "Welcome",
|
||||
description:
|
||||
"This collection is a quick guide to what Outline is all about. Feel free to delete this collection once your team is up to speed with the basics!",
|
||||
type: "atlas",
|
||||
teamId: this.id,
|
||||
creatorId: userId,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ const User = sequelize.define(
|
||||
service: { type: DataTypes.STRING, allowNull: true },
|
||||
serviceId: { type: DataTypes.STRING, allowNull: true, unique: true },
|
||||
slackData: DataTypes.JSONB,
|
||||
jwtSecret: encryptedFields().vault("jwtSecret"),
|
||||
jwtSecret: encryptedFields.vault("jwtSecret"),
|
||||
lastActiveAt: DataTypes.DATE,
|
||||
lastActiveIp: { type: DataTypes.STRING, allowNull: true },
|
||||
lastSignedInAt: DataTypes.DATE,
|
||||
|
||||
@@ -71,21 +71,7 @@ allow(User, "createChildDocument", Document, (user, document) => {
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, "move", Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (!document.publishedAt) return false;
|
||||
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
);
|
||||
if (cannot(user, "update", document.collection)) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
allow(User, ["pin", "unpin"], Document, (user, document) => {
|
||||
allow(User, ["move", "pin", "unpin"], Document, (user, document) => {
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
if (document.template) return false;
|
||||
@@ -126,16 +112,16 @@ allow(User, "restore", Document, (user, document) => {
|
||||
});
|
||||
|
||||
allow(User, "archive", Document, (user, document) => {
|
||||
if (!document.publishedAt) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
invariant(
|
||||
document.collection,
|
||||
"collection is missing, did you forget to include in the query scope?"
|
||||
);
|
||||
if (cannot(user, "update", document.collection)) return false;
|
||||
|
||||
if (!document.publishedAt) return false;
|
||||
if (document.archivedAt) return false;
|
||||
if (document.deletedAt) return false;
|
||||
|
||||
return user.teamId === document.teamId;
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function present(collection: Collection) {
|
||||
description: collection.description,
|
||||
icon: collection.icon,
|
||||
color: collection.color || "#4E5C6E",
|
||||
type: collection.type,
|
||||
private: collection.private,
|
||||
createdAt: collection.createdAt,
|
||||
updatedAt: collection.updatedAt,
|
||||
@@ -33,8 +34,10 @@ export default function present(collection: Collection) {
|
||||
documents: undefined,
|
||||
};
|
||||
|
||||
// Force alphabetical sorting
|
||||
data.documents = sortDocuments(collection.documentStructure);
|
||||
if (collection.type === "atlas") {
|
||||
// Force alphabetical sorting
|
||||
data.documents = sortDocuments(collection.documentStructure);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -72,13 +72,11 @@ export default async function present(document: Document, options: ?Options) {
|
||||
data.updatedBy = presentUser(document.updatedBy);
|
||||
|
||||
// TODO: This could be further optimized
|
||||
data.collaborators = (
|
||||
await User.findAll({
|
||||
where: {
|
||||
id: takeRight(document.collaboratorIds, 10) || [],
|
||||
},
|
||||
})
|
||||
).map(presentUser);
|
||||
data.collaborators = await User.findAll({
|
||||
where: {
|
||||
id: takeRight(document.collaboratorIds, 10) || [],
|
||||
},
|
||||
}).map(presentUser);
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
+6
-3
@@ -18,7 +18,7 @@ const readFile = util.promisify(fs.readFile);
|
||||
|
||||
const readIndexFile = async (ctx) => {
|
||||
if (isProduction) {
|
||||
return readFile(path.join(__dirname, "../app/index.html"));
|
||||
return readFile(path.join(__dirname, "../dist/index.html"));
|
||||
}
|
||||
|
||||
const middleware = ctx.devMiddleware;
|
||||
@@ -39,7 +39,7 @@ const readIndexFile = async (ctx) => {
|
||||
|
||||
// serve static assets
|
||||
koa.use(
|
||||
serve(path.resolve(__dirname, "../../public"), {
|
||||
serve(path.resolve(__dirname, "../public"), {
|
||||
maxage: 60 * 60 * 24 * 30 * 1000,
|
||||
})
|
||||
);
|
||||
@@ -52,7 +52,10 @@ if (process.env.NODE_ENV === "production") {
|
||||
"Cache-Control": `max-age=${356 * 24 * 60 * 60}`,
|
||||
});
|
||||
|
||||
await sendfile(ctx, path.join(__dirname, "../app/", ctx.path.substring(8)));
|
||||
await sendfile(
|
||||
ctx,
|
||||
path.join(__dirname, "../dist/", ctx.path.substring(8))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+5
-12
@@ -3,23 +3,16 @@ import debug from "debug";
|
||||
import Sequelize from "sequelize";
|
||||
import EncryptedField from "sequelize-encrypted";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const encryptedFields = () =>
|
||||
EncryptedField(Sequelize, process.env.SECRET_KEY);
|
||||
export const encryptedFields = EncryptedField(
|
||||
Sequelize,
|
||||
process.env.SECRET_KEY
|
||||
);
|
||||
|
||||
export const DataTypes = Sequelize;
|
||||
export const Op = Sequelize.Op;
|
||||
|
||||
export const sequelize = new Sequelize(process.env.DATABASE_URL, {
|
||||
// logging: console.log,
|
||||
logging: debug("sql"),
|
||||
typeValidation: true,
|
||||
dialectOptions: {
|
||||
ssl: isProduction
|
||||
? {
|
||||
// Ref.: https://github.com/brianc/node-postgres/issues/2009
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @flow
|
||||
import { difference } from "lodash";
|
||||
import parseDocumentIds from "../../shared/utils/parseDocumentIds";
|
||||
import type { DocumentEvent } from "../events";
|
||||
import { Document, Revision, Backlink } from "../models";
|
||||
import parseDocumentIds from "../utils/parseDocumentIds";
|
||||
import slugify from "../utils/slugify";
|
||||
|
||||
export default class Backlinks {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import subHours from "date-fns/sub_hours";
|
||||
import { socketio } from "../";
|
||||
import type { Event } from "../events";
|
||||
import { socketio } from "../main";
|
||||
import {
|
||||
Document,
|
||||
Collection,
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>//inject-env//</script>
|
||||
<script src="https://browser.sentry-cdn.com/5.22.3/bundle.min.js"
|
||||
integrity="sha384-A1qzcXXJWl+bzYr+r8AdFzSaLbdcbYRFmG37MEDKr4EYjtraUyoZ6UiMw31jHcV9" crossorigin="anonymous">
|
||||
<script src="https://browser.sentry-cdn.com/5.12.1/bundle.min.js"
|
||||
integrity="sha384-y+an4eARFKvjzOivf/Z7JtMJhaN6b+lLQ5oFbBbUwZNNVir39cYtkjW1r6Xjbxg3" crossorigin="anonymous">
|
||||
</script>
|
||||
<script>
|
||||
if ('//inject-sentry-dsn//') {
|
||||
@@ -44,7 +44,6 @@
|
||||
'NotFoundError',
|
||||
'OfflineError',
|
||||
'UpdateRequiredError',
|
||||
'ChunkLoadError'
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export async function buildCollection(overrides: Object = {}) {
|
||||
name: `Test Collection ${count}`,
|
||||
description: "Test collection description",
|
||||
creatorId: overrides.userId,
|
||||
type: "atlas",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,4 +28,5 @@ function runMigrations() {
|
||||
runMigrations();
|
||||
|
||||
// This is needed for the relative manual mock to be picked up
|
||||
// $FlowFixMe
|
||||
jest.mock("../events");
|
||||
|
||||
@@ -6,9 +6,7 @@ export function flushdb() {
|
||||
const sql = sequelize.getQueryInterface();
|
||||
const tables = Object.keys(sequelize.models).map((model) => {
|
||||
const n = sequelize.models[model].getTableName();
|
||||
return sql.queryGenerator.quoteTable(
|
||||
typeof n === "string" ? n : n.tableName
|
||||
);
|
||||
return sql.quoteTable(typeof n === "string" ? n : n.tableName);
|
||||
});
|
||||
|
||||
const query = `TRUNCATE ${tables.join(", ")} CASCADE`;
|
||||
@@ -62,6 +60,7 @@ const seed = async () => {
|
||||
urlId: "collection",
|
||||
teamId: team.id,
|
||||
creatorId: user.id,
|
||||
type: "atlas",
|
||||
});
|
||||
|
||||
const document = await Document.create({
|
||||
|
||||
@@ -16,7 +16,7 @@ const prefetchTags = [
|
||||
|
||||
try {
|
||||
const manifest = fs.readFileSync(
|
||||
path.join(__dirname, "../../app/manifest.json"),
|
||||
path.join(__dirname, "../../dist/manifest.json"),
|
||||
"utf8"
|
||||
);
|
||||
const manifestData = JSON.parse(manifest);
|
||||
|
||||
+15
-12
@@ -4,18 +4,18 @@ import * as Sentry from "@sentry/node";
|
||||
import AWS from "aws-sdk";
|
||||
import addHours from "date-fns/add_hours";
|
||||
import format from "date-fns/format";
|
||||
import invariant from "invariant";
|
||||
import fetch from "isomorphic-fetch";
|
||||
|
||||
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
|
||||
const AWS_REGION = process.env.AWS_REGION;
|
||||
const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME || "";
|
||||
const AWS_S3_FORCE_PATH_STYLE = process.env.AWS_S3_FORCE_PATH_STYLE !== "false";
|
||||
const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME;
|
||||
|
||||
const s3 = new AWS.S3({
|
||||
s3ForcePathStyle: AWS_S3_FORCE_PATH_STYLE,
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
s3ForcePathStyle: true,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
endpoint: new AWS.Endpoint(process.env.AWS_S3_UPLOAD_BUCKET_URL),
|
||||
signatureVersion: "v4",
|
||||
});
|
||||
@@ -84,9 +84,9 @@ export const publicS3Endpoint = (isServerUpload?: boolean) => {
|
||||
"localhost:"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
return `${host}/${
|
||||
isServerUpload && isDocker ? "s3/" : ""
|
||||
}${AWS_S3_UPLOAD_BUCKET_NAME}`;
|
||||
return `${host}/${isServerUpload && isDocker ? "s3/" : ""}${
|
||||
process.env.AWS_S3_UPLOAD_BUCKET_NAME
|
||||
}`;
|
||||
};
|
||||
|
||||
export const uploadToS3FromUrl = async (
|
||||
@@ -94,6 +94,8 @@ export const uploadToS3FromUrl = async (
|
||||
key: string,
|
||||
acl: string
|
||||
) => {
|
||||
invariant(AWS_S3_UPLOAD_BUCKET_NAME, "AWS_S3_UPLOAD_BUCKET_NAME not set");
|
||||
|
||||
try {
|
||||
// $FlowIssue https://github.com/facebook/flow/issues/2171
|
||||
const res = await fetch(url);
|
||||
@@ -101,7 +103,7 @@ export const uploadToS3FromUrl = async (
|
||||
await s3
|
||||
.putObject({
|
||||
ACL: acl,
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
ContentType: res.headers["content-type"],
|
||||
ContentLength: res.headers["content-length"],
|
||||
@@ -124,17 +126,18 @@ export const uploadToS3FromUrl = async (
|
||||
export const deleteFromS3 = (key: string) => {
|
||||
return s3
|
||||
.deleteObject({
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
})
|
||||
.promise();
|
||||
};
|
||||
|
||||
export const getSignedImageUrl = async (key: string) => {
|
||||
invariant(AWS_S3_UPLOAD_BUCKET_NAME, "AWS_S3_UPLOAD_BUCKET_NAME not set");
|
||||
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
|
||||
|
||||
const params = {
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
Expires: 60,
|
||||
};
|
||||
@@ -146,7 +149,7 @@ export const getSignedImageUrl = async (key: string) => {
|
||||
|
||||
export const getImageByKey = async (key: string) => {
|
||||
const params = {
|
||||
Bucket: AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
|
||||
Key: key,
|
||||
};
|
||||
|
||||
|
||||
@@ -101,16 +101,6 @@ export const base = {
|
||||
desktop: 1025, // targeting devices that are larger than the iPad (which is 1024px in landscape mode)
|
||||
desktopLarge: 1600,
|
||||
},
|
||||
|
||||
depths: {
|
||||
sidebar: 1000,
|
||||
modalOverlay: 2000,
|
||||
modal: 3000,
|
||||
menu: 4000,
|
||||
toasts: 5000,
|
||||
loadingIndicatorBar: 6000,
|
||||
popover: 9000,
|
||||
},
|
||||
};
|
||||
|
||||
export const light = {
|
||||
|
||||
@@ -37,3 +37,14 @@ export default function parseDocumentIds(text: string): string[] {
|
||||
findLinks(value);
|
||||
return links;
|
||||
}
|
||||
|
||||
export function parseDocumentSlugFromUrl(url: string) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parsed.pathname.replace(/^\/doc\//, "");
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// @flow
|
||||
|
||||
export function parseDocumentSlugFromUrl(url: string) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parsed.pathname.replace(/^\/doc\//, "");
|
||||
}
|
||||
@@ -4,20 +4,19 @@ const commonWebpackConfig = require("./webpack.config");
|
||||
|
||||
const developmentWebpackConfig = Object.assign(commonWebpackConfig, {
|
||||
cache: true,
|
||||
mode: "development",
|
||||
devtool: "eval-source-map",
|
||||
entry: [
|
||||
"webpack-hot-middleware/client",
|
||||
"./app/index",
|
||||
],
|
||||
optimization: {
|
||||
usedExports: true,
|
||||
},
|
||||
});
|
||||
|
||||
developmentWebpackConfig.plugins = [
|
||||
...developmentWebpackConfig.plugins,
|
||||
new webpack.HotModuleReplacementPlugin()
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
"process.env.NODE_ENV": JSON.stringify("development"),
|
||||
}),
|
||||
];
|
||||
|
||||
module.exports = developmentWebpackConfig;
|
||||
|
||||
+17
-35
@@ -2,38 +2,37 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const { RelativeCiAgentWebpackPlugin } = require('@relative-ci/agent');
|
||||
const pkg = require("rich-markdown-editor/package.json");
|
||||
|
||||
require('dotenv').config({ silent: true });
|
||||
|
||||
module.exports = {
|
||||
output: {
|
||||
path: path.join(__dirname, 'build/app'),
|
||||
filename: '[name].[hash].js',
|
||||
path: path.join(__dirname, 'dist'),
|
||||
filename: 'bundle.js',
|
||||
publicPath: '/static/',
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: [
|
||||
path.join(__dirname, 'node_modules')
|
||||
],
|
||||
include: [
|
||||
path.join(__dirname, 'app'),
|
||||
path.join(__dirname, 'shared'),
|
||||
],
|
||||
options: {
|
||||
cacheDirectory: true
|
||||
}
|
||||
test: /\.js$/,
|
||||
loader: 'babel-loader',
|
||||
exclude: [
|
||||
path.join(__dirname, 'node_modules')
|
||||
],
|
||||
include: [
|
||||
path.join(__dirname, 'app'),
|
||||
path.join(__dirname, 'shared'),
|
||||
],
|
||||
options: {
|
||||
cacheDirectory: true
|
||||
}
|
||||
},
|
||||
{ test: /\.json$/, loader: 'json-loader' },
|
||||
// inline base64 URLs for <=8k images, direct URLs for the rest
|
||||
{ test: /\.(png|jpg|svg)$/, loader: 'url-loader' },
|
||||
{
|
||||
test: /\.woff$/,
|
||||
loader: 'url-loader?limit=1&mimetype=application/font-woff&name=public/fonts/[name].[ext]',
|
||||
test: /\.woff$/,
|
||||
loader: 'url-loader?limit=1&mimetype=application/font-woff&name=public/fonts/[name].[ext]',
|
||||
},
|
||||
{ test: /\.md/, loader: 'raw-loader' },
|
||||
]
|
||||
@@ -49,9 +48,6 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
EDITOR_VERSION: JSON.stringify(pkg.version)
|
||||
}),
|
||||
new webpack.ProvidePlugin({
|
||||
fetch: 'imports-loader?this=>global!exports-loader?global.fetch!isomorphic-fetch',
|
||||
}),
|
||||
@@ -59,22 +55,8 @@ module.exports = {
|
||||
new HtmlWebpackPlugin({
|
||||
template: 'server/static/index.html',
|
||||
}),
|
||||
new RelativeCiAgentWebpackPlugin(),
|
||||
],
|
||||
stats: {
|
||||
assets: false,
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: 'single',
|
||||
moduleIds: 'hashed',
|
||||
splitChunks: {
|
||||
cacheGroups: {
|
||||
vendor: {
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendors',
|
||||
chunks: 'initial',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
+17
-30
@@ -1,48 +1,35 @@
|
||||
/* eslint-disable */
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
|
||||
const ManifestPlugin = require('webpack-manifest-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
commonWebpackConfig = require('./webpack.config');
|
||||
|
||||
productionWebpackConfig = Object.assign(commonWebpackConfig, {
|
||||
output: {
|
||||
path: path.join(__dirname, 'build/app'),
|
||||
filename: '[name].[contenthash].js',
|
||||
publicPath: '/static/',
|
||||
},
|
||||
cache: true,
|
||||
mode: "production",
|
||||
devtool: 'source-map',
|
||||
entry: ['./app/index'],
|
||||
stats: "normal",
|
||||
optimization: {
|
||||
...commonWebpackConfig.optimization,
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
terserOptions: {
|
||||
ecma: undefined,
|
||||
parse: {},
|
||||
compress: {},
|
||||
mangle: true, // Note `mangle.properties` is `false` by default.
|
||||
module: false,
|
||||
output: null,
|
||||
toplevel: false,
|
||||
nameCache: null,
|
||||
ie8: false,
|
||||
keep_classnames: undefined,
|
||||
keep_fnames: true,
|
||||
safari10: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
output: {
|
||||
path: path.join(__dirname, 'dist'),
|
||||
filename: 'bundle.[hash].js',
|
||||
publicPath: '/static/',
|
||||
},
|
||||
stats: "normal"
|
||||
});
|
||||
|
||||
productionWebpackConfig.plugins = [
|
||||
...productionWebpackConfig.plugins,
|
||||
new ManifestPlugin()
|
||||
new ManifestPlugin(),
|
||||
new UglifyJsPlugin({
|
||||
sourceMap: true,
|
||||
uglifyOptions: {
|
||||
compress: true,
|
||||
keep_fnames: true
|
||||
}
|
||||
}),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
}),
|
||||
];
|
||||
|
||||
module.exports = productionWebpackConfig;
|
||||
|
||||
Reference in New Issue
Block a user