mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
129 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3e3651222 | |||
| b3d9478486 | |||
| f26aeca46a | |||
| f1a95e5e79 | |||
| 6f1f855083 | |||
| 40d52e9a78 | |||
| a2f2971fec | |||
| d89808ce9d | |||
| a43cc9c5a9 | |||
| bb7fcd1b67 | |||
| c1957025ec | |||
| 98626ebbaf | |||
| 0fa8a6ed2e | |||
| fa96891c8e | |||
| 9aa81dcf82 | |||
| c04d5bdfb0 | |||
| ea69d09562 | |||
| c8ff5cf221 | |||
| 5638f7a687 | |||
| 1293f52552 | |||
| 86812cfe76 | |||
| d9b7384853 | |||
| 292afd774d | |||
| d3d286b1be | |||
| 26b9566b96 | |||
| d487da8f15 | |||
| 4ffc04bc5d | |||
| 68148bd4d8 | |||
| 881105992e | |||
| 2c1a111dee | |||
| e67d319e2b | |||
| 85f7e03921 | |||
| e30adbaac2 | |||
| ac8f0ebaac | |||
| b3b71d2dc7 | |||
| ab3613af48 | |||
| 3940f1a108 | |||
| 142e7da6a5 | |||
| 021de66f7a | |||
| 55858d5d7d | |||
| 93d3582ac7 | |||
| 0b2107c1ee | |||
| f8a167fd4b | |||
| 608be3deef | |||
| 56551d1ab3 | |||
| 450d6b7e42 | |||
| fc98cf78e6 | |||
| d9aa53a094 | |||
| ffab4fbf76 | |||
| 2161fba1dd | |||
| cd2cdd025c | |||
| d5f5319f80 | |||
| c298c73240 | |||
| 38d1831259 | |||
| 9c9b95741c | |||
| cc8db7e991 | |||
| c5b7d9be13 | |||
| be2e46b5d2 | |||
| 4b2a766357 | |||
| f264d67862 | |||
| b1648ac2aa | |||
| 25423d8c85 | |||
| e7ab2939d4 | |||
| ceeac9b982 | |||
| 5de2f969e3 | |||
| b54901d50c | |||
| 4de3f69474 | |||
| c5de2da115 | |||
| 709c3e78bd | |||
| acb04fdf1a | |||
| f13696dd2a | |||
| d6f245d67e | |||
| f2abf38fe4 | |||
| f0712e22d8 | |||
| e7e289d9fa | |||
| 713187cfb4 | |||
| 11d3a5c9b9 | |||
| cf1e506009 | |||
| 6b6d67beb6 | |||
| a98e8ad8df | |||
| 9049785d98 | |||
| e8648d4611 | |||
| dd7436f78c | |||
| b93a397ab3 | |||
| 206160582e | |||
| 4bf5926ee3 | |||
| 82433e02a0 | |||
| 637a9b5cf9 | |||
| 95b91c466a | |||
| 4edf90a184 | |||
| 31522b0d6f | |||
| 759d4a5ac2 | |||
| dd0d51dd9d | |||
| 8550116c6b | |||
| 3c7dc93982 | |||
| 0aa338cccc | |||
| 8f41895e66 | |||
| de8ac4acf5 | |||
| de59147418 | |||
| cf522cc85f | |||
| 8c7200fa87 | |||
| f2310be173 | |||
| 29f4dc9331 | |||
| 03b6dd62a8 | |||
| 7f0c608dbb | |||
| c52fbb944e | |||
| e22e952606 | |||
| 197cdff6c3 | |||
| 85d09b2351 | |||
| 69611638b9 | |||
| e117d5f103 | |||
| 03db975217 | |||
| 76279902f9 | |||
| a304e91ffc | |||
| 9b5573c5e2 | |||
| ec61efa12b | |||
| b01778a39f | |||
| 5aa092853b | |||
| 1fa3db4bdc | |||
| 6a9f74e6cc | |||
| e8719340d1 | |||
| 70838918c3 | |||
| ec38f5d79c | |||
| 179176c312 | |||
| c446a91f7d | |||
| 05f48f054b | |||
| ec55299c8b | |||
| 26c574ab58 | |||
| 6dd6768f07 |
@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/outline
|
||||
docker:
|
||||
- image: circleci/node:12
|
||||
- image: circleci/node:14
|
||||
- image: circleci/redis:latest
|
||||
- image: circleci/postgres:9.6.5-alpine-ram
|
||||
environment:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
__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
|
||||
+2
-1
@@ -14,7 +14,7 @@ URL=http://localhost:3000
|
||||
PORT=3000
|
||||
|
||||
# enforce (auto redirect to) https in production, (optional) default is true.
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
# set to false if your SSL is terminated at a loadbalancer, for example
|
||||
FORCE_HTTPS=true
|
||||
|
||||
ENABLE_UPDATES=true
|
||||
@@ -45,6 +45,7 @@ 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,7 +4,8 @@
|
||||
"react-app",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:flowtype/recommended"
|
||||
"plugin:flowtype/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
"prettier",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
dist
|
||||
build
|
||||
node_modules/*
|
||||
server/scripts
|
||||
.env
|
||||
@@ -7,3 +8,4 @@ npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
fakes3/*
|
||||
.idea
|
||||
|
||||
+14
-7
@@ -1,17 +1,24 @@
|
||||
FROM node:12-alpine
|
||||
FROM node:14-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
|
||||
|
||||
RUN yarn install --pure-lockfile
|
||||
RUN yarn build
|
||||
RUN cp -r /opt/outline/node_modules /opt/node_modules
|
||||
COPY package.json ./
|
||||
COPY yarn.lock ./
|
||||
|
||||
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.47.1
|
||||
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-09-11
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -22,13 +22,37 @@ If you'd like to run your own copy of Outline or contribute to development then
|
||||
|
||||
Outline requires the following dependencies:
|
||||
|
||||
- Node.js >= 12
|
||||
- Postgres >=9.5
|
||||
- Redis >= 4
|
||||
- AWS S3 storage bucket for media and other attachments
|
||||
- [Node.js](https://nodejs.org/) >= 12
|
||||
- [Yarn](https://yarnpkg.com)
|
||||
- [Postgres](https://www.postgresql.org/download/) >=9.5
|
||||
- [Redis](https://redis.io/) >= 4
|
||||
- AWS S3 bucket or compatible API for file storage
|
||||
- Slack or Google developer application for authentication
|
||||
|
||||
|
||||
### Production
|
||||
|
||||
For a manual self-hosted production installation these are the suggested steps:
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn install`
|
||||
1. Build the source code with `yarn build`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate`
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start ./build/server/index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed using the `PORT` environment variable
|
||||
|
||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
|
||||
|
||||
### Development
|
||||
|
||||
In development you can quickly get an environment running using Docker by following these steps:
|
||||
@@ -50,32 +74,6 @@ In development you can quickly get an environment running using Docker by follow
|
||||
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
|
||||
|
||||
|
||||
### Production
|
||||
|
||||
For a self-hosted production installation there is more flexibility, but these are the suggested steps:
|
||||
|
||||
1. Clone this repo and install dependencies with `yarn` or `npm install`
|
||||
|
||||
> Requires [Node.js](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed
|
||||
|
||||
1. Build the web app with `yarn build:webpack` or `npm run build:webpack`
|
||||
1. Using the `.env.sample` as a reference, set the required variables in your production environment. The following are required as a minimum:
|
||||
1. `SECRET_KEY` (follow instructions in the comments at the top of `.env`)
|
||||
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
|
||||
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
|
||||
1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service)
|
||||
1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service)
|
||||
1. `URL` (the public facing URL of your installation)
|
||||
1. `AWS_` (all of the keys beginning with AWS)
|
||||
1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate `
|
||||
1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline `
|
||||
1. Visit http://you_server_ip:3000 and you should be able to see Outline page
|
||||
|
||||
> Port number can be changed in the `.env` file
|
||||
|
||||
1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc.
|
||||
|
||||
|
||||
## Development
|
||||
|
||||
### Server
|
||||
|
||||
@@ -92,6 +92,11 @@
|
||||
"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",
|
||||
|
||||
@@ -4,9 +4,10 @@ import styled from "styled-components";
|
||||
const Badge = styled.span`
|
||||
margin-left: 10px;
|
||||
padding: 2px 6px 3px;
|
||||
background-color: ${({ primary, theme }) =>
|
||||
primary ? theme.primary : theme.textTertiary};
|
||||
color: ${({ primary, theme }) => (primary ? theme.white : theme.background)};
|
||||
background-color: ${({ yellow, primary, theme }) =>
|
||||
yellow ? theme.yellow : primary ? theme.primary : theme.textTertiary};
|
||||
color: ${({ primary, yellow, theme }) =>
|
||||
primary ? theme.white : yellow ? theme.almostBlack : theme.background};
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import {
|
||||
PadlockIcon,
|
||||
ArchiveIcon,
|
||||
EditIcon,
|
||||
GoToIcon,
|
||||
MoreIcon,
|
||||
PadlockIcon,
|
||||
ShapesIcon,
|
||||
EditIcon,
|
||||
TrashIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -25,11 +27,73 @@ type Props = {
|
||||
onlyText: boolean,
|
||||
};
|
||||
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
const collection = collections.get(document.collectionId);
|
||||
if (!collection) return <div />;
|
||||
function Icon({ document }) {
|
||||
if (document.isDeleted) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/trash">
|
||||
<TrashIcon color="currentColor" />
|
||||
|
||||
<span>Trash</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isArchived) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/archive">
|
||||
<ArchiveIcon color="currentColor" />
|
||||
|
||||
<span>Archive</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isDraft) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (document.isTemplate) {
|
||||
return (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument(document).slice(0, -1);
|
||||
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
let collection = collections.get(document.collectionId);
|
||||
if (!collection) {
|
||||
if (!document.deletedAt) return <div />;
|
||||
|
||||
collection = {
|
||||
id: document.collectionId,
|
||||
name: "Deleted Collection",
|
||||
color: "currentColor",
|
||||
};
|
||||
}
|
||||
|
||||
const path = collection.pathToDocument
|
||||
? collection.pathToDocument(document).slice(0, -1)
|
||||
: [];
|
||||
|
||||
if (onlyText === true) {
|
||||
return (
|
||||
@@ -50,34 +114,13 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const isTemplate = document.isTemplate;
|
||||
const isDraft = !document.publishedAt && !isTemplate;
|
||||
const isNestedDocument = path.length > 1;
|
||||
const lastPath = path.length ? path[path.length - 1] : undefined;
|
||||
const menuPath = isNestedDocument ? path.slice(0, -1) : [];
|
||||
|
||||
return (
|
||||
<Wrapper justify="flex-start" align="center">
|
||||
{isTemplate && (
|
||||
<>
|
||||
<CollectionName to="/templates">
|
||||
<ShapesIcon color="currentColor" />
|
||||
|
||||
<span>Templates</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
{isDraft && (
|
||||
<>
|
||||
<CollectionName to="/drafts">
|
||||
<EditIcon color="currentColor" />
|
||||
|
||||
<span>Drafts</span>
|
||||
</CollectionName>
|
||||
<Slash />
|
||||
</>
|
||||
)}
|
||||
<Icon document={document} />
|
||||
<CollectionName to={collectionUrl(collection.id)}>
|
||||
<CollectionIcon collection={collection} expanded />
|
||||
|
||||
@@ -127,12 +170,12 @@ export const Slash = styled(GoToIcon)`
|
||||
|
||||
const Overflow = styled(MoreIcon)`
|
||||
flex-shrink: 0;
|
||||
opacity: 0.25;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
opacity: 1;
|
||||
&:active,
|
||||
&:hover {
|
||||
fill: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -19,7 +19,6 @@ const RealButton = styled.button`
|
||||
height: 32px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
@@ -36,13 +35,6 @@ const RealButton = styled.button`
|
||||
background: ${(props) => darken(0.05, props.theme.buttonBackground)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
@@ -70,13 +62,6 @@ const RealButton = styled.button`
|
||||
border: 1px solid ${props.theme.buttonNeutralBorder};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
border: 1px solid ${lighten(0.4, props.theme.buttonBackground)};
|
||||
box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 2px;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${props.theme.textTertiary};
|
||||
}
|
||||
@@ -89,12 +74,6 @@ const RealButton = styled.button`
|
||||
&:hover {
|
||||
background: ${darken(0.05, props.theme.danger)};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px
|
||||
0px 3px;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function ResolvedCollectionIcon({ collection, expanded, size, ui }: Props) {
|
||||
// If the chosen icon color is very dark then we invert it in dark mode
|
||||
// otherwise it will be impossible to see against the dark background.
|
||||
const color =
|
||||
ui.resolvedTheme === "dark"
|
||||
ui.resolvedTheme === "dark" && collection.color !== "currentColor"
|
||||
? getLuminance(collection.color) > 0.12
|
||||
? collection.color
|
||||
: "currentColor"
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function DelayedMount({ delay = 250, children }: Props) {
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
}, [delay]);
|
||||
|
||||
if (!isShowing) {
|
||||
return null;
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
// @flow
|
||||
import ArrowKeyNavigation from "boundless-arrow-key-navigation";
|
||||
import { observable, action } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { action, observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { CloseIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { type RouterHistory, type Match } from "react-router-dom";
|
||||
import { type Match, Redirect, type RouterHistory } 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 } from "utils/routeHelpers";
|
||||
import { documentHistoryUrl, documentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
match: Match,
|
||||
@@ -29,6 +32,7 @@ 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();
|
||||
@@ -86,15 +90,34 @@ 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} />
|
||||
@@ -140,10 +163,37 @@ 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};
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export default inject("documents", "revisions")(DocumentHistory);
|
||||
|
||||
@@ -36,7 +36,7 @@ class RevisionListItem extends React.Component<Props> {
|
||||
{revision.createdBy.name}
|
||||
</Author>
|
||||
<Meta>
|
||||
<Time dateTime={revision.createdAt}>
|
||||
<Time dateTime={revision.createdAt} tooltipDelay={250}>
|
||||
{format(revision.createdAt, "MMMM Do, YYYY h:mm a")}
|
||||
</Time>
|
||||
</Meta>
|
||||
|
||||
@@ -18,8 +18,7 @@ const Container = styled(Flex)`
|
||||
`;
|
||||
|
||||
const Modified = styled.span`
|
||||
color: ${(props) =>
|
||||
props.highlight ? props.theme.text : props.theme.textTertiary};
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-weight: ${(props) => (props.highlight ? "600" : "400")};
|
||||
`;
|
||||
|
||||
@@ -28,6 +27,7 @@ type Props = {
|
||||
auth: AuthStore,
|
||||
showCollection?: boolean,
|
||||
showPublished?: boolean,
|
||||
showLastViewed?: boolean,
|
||||
document: Document,
|
||||
children: React.Node,
|
||||
to?: string,
|
||||
@@ -38,6 +38,7 @@ function DocumentMeta({
|
||||
collections,
|
||||
showPublished,
|
||||
showCollection,
|
||||
showLastViewed,
|
||||
document,
|
||||
children,
|
||||
to,
|
||||
@@ -52,6 +53,7 @@ function DocumentMeta({
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
isDraft,
|
||||
lastViewedAt,
|
||||
} = document;
|
||||
|
||||
// Prevent meta information from displaying if updatedBy is not available.
|
||||
@@ -65,37 +67,37 @@ function DocumentMeta({
|
||||
if (deletedAt) {
|
||||
content = (
|
||||
<span>
|
||||
deleted <Time dateTime={deletedAt} /> ago
|
||||
deleted <Time dateTime={deletedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (archivedAt) {
|
||||
content = (
|
||||
<span>
|
||||
archived <Time dateTime={archivedAt} /> ago
|
||||
archived <Time dateTime={archivedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (createdAt === updatedAt) {
|
||||
content = (
|
||||
<span>
|
||||
created <Time dateTime={updatedAt} /> ago
|
||||
created <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
|
||||
content = (
|
||||
<span>
|
||||
published <Time dateTime={publishedAt} /> ago
|
||||
published <Time dateTime={publishedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else if (isDraft) {
|
||||
content = (
|
||||
<span>
|
||||
saved <Time dateTime={updatedAt} /> ago
|
||||
saved <Time dateTime={updatedAt} addSuffix />
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<Modified highlight={modifiedSinceViewed}>
|
||||
updated <Time dateTime={updatedAt} /> ago
|
||||
updated <Time dateTime={updatedAt} addSuffix />
|
||||
</Modified>
|
||||
);
|
||||
}
|
||||
@@ -103,6 +105,25 @@ function DocumentMeta({
|
||||
const collection = collections.get(document.collectionId);
|
||||
const updatedByMe = auth.user && auth.user.id === updatedBy.id;
|
||||
|
||||
const timeSinceNow = () => {
|
||||
if (isDraft || !showLastViewed) {
|
||||
return null;
|
||||
}
|
||||
if (!lastViewedAt) {
|
||||
return (
|
||||
<>
|
||||
• <Modified highlight>Never viewed</Modified>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
• Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container align="center" {...rest}>
|
||||
{updatedByMe ? "You" : updatedBy.name}
|
||||
@@ -115,6 +136,7 @@ function DocumentMeta({
|
||||
</strong>
|
||||
</span>
|
||||
)}
|
||||
{timeSinceNow()}
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// @flow
|
||||
import { inject } from "mobx-react";
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
import useStores from "../hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
views: ViewsStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
to?: string,
|
||||
|};
|
||||
|
||||
function DocumentMetaWithViews({ views, to, isDraft, document }: Props) {
|
||||
const totalViews = views.countForDocument(document.id);
|
||||
function DocumentMetaWithViews({ to, isDraft, document }: Props) {
|
||||
const { views } = useStores();
|
||||
const totalViews = useObserver(() => views.countForDocument(document.id));
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to}>
|
||||
@@ -45,4 +45,4 @@ const Meta = styled(DocumentMeta)`
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("views")(DocumentMetaWithViews);
|
||||
export default DocumentMetaWithViews;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { StarredIcon, PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Link, withRouter, type RouterHistory } from "react-router-dom";
|
||||
import { Link, Redirect } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
import Document from "models/Document";
|
||||
import Badge from "components/Badge";
|
||||
@@ -15,7 +16,6 @@ import DocumentMenu from "menus/DocumentMenu";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
document: Document,
|
||||
highlight?: ?string,
|
||||
context?: ?string,
|
||||
@@ -30,6 +30,8 @@ const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
|
||||
|
||||
@observer
|
||||
class DocumentPreview extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
|
||||
handleStar = (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
@@ -48,17 +50,15 @@ class DocumentPreview extends React.Component<Props> {
|
||||
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
|
||||
};
|
||||
|
||||
handleNewFromTemplate = (event) => {
|
||||
handleNewFromTemplate = (event: SyntheticEvent<>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const { document } = this.props;
|
||||
|
||||
this.props.history.push(
|
||||
newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
})
|
||||
);
|
||||
this.redirectTo = newDocumentUrl(document.collectionId, {
|
||||
templateId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
@@ -73,6 +73,10 @@ class DocumentPreview extends React.Component<Props> {
|
||||
context,
|
||||
} = this.props;
|
||||
|
||||
if (this.redirectTo) {
|
||||
return <Redirect to={this.redirectTo} push />;
|
||||
}
|
||||
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
@@ -86,6 +90,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
>
|
||||
<Heading>
|
||||
<Title text={document.titleWithDefault} highlight={highlight} />
|
||||
{document.isNew && <Badge yellow>New</Badge>}
|
||||
{!document.isDraft &&
|
||||
!document.isArchived &&
|
||||
!document.isTemplate && (
|
||||
@@ -133,6 +138,7 @@ class DocumentPreview extends React.Component<Props> {
|
||||
document={document}
|
||||
showCollection={showCollection}
|
||||
showPublished={showPublished}
|
||||
showLastViewed
|
||||
/>
|
||||
</DocumentLink>
|
||||
);
|
||||
@@ -181,7 +187,6 @@ const DocumentLink = styled(Link)`
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${SecondaryActions} {
|
||||
opacity: 1;
|
||||
@@ -229,4 +234,4 @@ const ResultContext = styled(Highlight)`
|
||||
margin-bottom: 0.25em;
|
||||
`;
|
||||
|
||||
export default withRouter(DocumentPreview);
|
||||
export default DocumentPreview;
|
||||
|
||||
@@ -8,7 +8,6 @@ import { withRouter, type RouterHistory, type Match } from "react-router-dom";
|
||||
import { createGlobalStyle } from "styled-components";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import LoadingIndicator from "components/LoadingIndicator";
|
||||
import importFile from "utils/importFile";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
let importingLock = false;
|
||||
@@ -61,12 +60,12 @@ class DropToImport extends React.Component<Props> {
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const doc = await importFile({
|
||||
documents: this.props.documents,
|
||||
const doc = await this.props.documents.import(
|
||||
file,
|
||||
documentId,
|
||||
collectionId,
|
||||
});
|
||||
{ publish: true }
|
||||
);
|
||||
|
||||
if (redirect) {
|
||||
this.props.history.push(doc.url);
|
||||
@@ -95,7 +94,7 @@ class DropToImport extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept="text/markdown, text/plain"
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
style={EMPTY_OBJECT}
|
||||
disableClick
|
||||
|
||||
@@ -177,6 +177,7 @@ class DropdownMenu extends React.Component<Props> {
|
||||
{label || (
|
||||
<NudeButton
|
||||
id={`${this.id}button`}
|
||||
aria-label="More options"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={isOpen ? "true" : "false"}
|
||||
aria-controls={this.id}
|
||||
|
||||
@@ -80,7 +80,6 @@ const MenuItem = styled.a`
|
||||
&:focus {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.theme.primary};
|
||||
outline: none;
|
||||
}
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -34,14 +34,14 @@ class Editor extends React.Component<PropsWithRef> {
|
||||
return result.url;
|
||||
};
|
||||
|
||||
onClickLink = (href: string) => {
|
||||
onClickLink = (href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (href[0] === "#") {
|
||||
window.location.href = href;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInternalUrl(href)) {
|
||||
if (isInternalUrl(href) && !event.metaKey && !event.shiftKey) {
|
||||
// relative
|
||||
let navigateTo = href;
|
||||
|
||||
|
||||
@@ -55,7 +55,26 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!window.Sentry && env.DEPLOYMENT === "hosted";
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
return (
|
||||
<CenteredContent>
|
||||
<PageTitle title="Module failed to load" />
|
||||
<h1>Loading Failed</h1>
|
||||
<HelpText>
|
||||
Sorry, part of the application failed to load. This may be because
|
||||
it was updated since you opened the tab or because of a failed
|
||||
network request. Please try reloading.
|
||||
</HelpText>
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>
|
||||
</p>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredContent>
|
||||
@@ -66,7 +85,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
{isReported && " – our engineers have been notified"}. Please try
|
||||
reloading the page, it may have been a temporary glitch.
|
||||
</HelpText>
|
||||
{this.showDetails && <Pre>{this.error.toString()}</Pre>}
|
||||
{this.showDetails && <Pre>{error.toString()}</Pre>}
|
||||
<p>
|
||||
<Button onClick={this.handleReload}>Reload</Button>{" "}
|
||||
{this.showDetails ? (
|
||||
|
||||
@@ -38,7 +38,7 @@ function Highlight({
|
||||
}
|
||||
|
||||
const Mark = styled.mark`
|
||||
background: ${(props) => props.theme.yellow};
|
||||
background: ${(props) => props.theme.searchHighlight};
|
||||
border-radius: 2px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
@@ -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 parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
@@ -20,13 +20,8 @@ type Props = {
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
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);
|
||||
function HoverPreviewInternal({ node, documents, onClose, event }: Props) {
|
||||
const slug = parseDocumentSlug(node.href);
|
||||
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef();
|
||||
@@ -131,6 +126,15 @@ function HoverPreview({ 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;
|
||||
|
||||
@@ -211,6 +215,7 @@ const Pointer = styled.div`
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
|
||||
@@ -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 parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
const slug = parseDocumentSlugFromUrl(url);
|
||||
const slug = parseDocumentSlug(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { searchUrl } from "utils/routeHelpers";
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
theme: Object,
|
||||
source: string,
|
||||
placeholder?: string,
|
||||
collectionId?: string,
|
||||
};
|
||||
@@ -33,7 +34,10 @@ class InputSearch extends React.Component<Props> {
|
||||
handleSearchInput = (ev) => {
|
||||
ev.preventDefault();
|
||||
this.props.history.push(
|
||||
searchUrl(ev.target.value, this.props.collectionId)
|
||||
searchUrl(ev.target.value, {
|
||||
collectionId: this.props.collectionId,
|
||||
ref: this.props.source,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -44,21 +44,22 @@ class Layout extends React.Component<Props> {
|
||||
@observable redirectTo: ?string;
|
||||
@observable keyboardShortcutsOpen: boolean = false;
|
||||
|
||||
componentWillMount() {
|
||||
this.updateBackground();
|
||||
constructor(props) {
|
||||
super();
|
||||
this.updateBackground(props);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.updateBackground();
|
||||
this.updateBackground(this.props);
|
||||
|
||||
if (this.redirectTo) {
|
||||
this.redirectTo = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
updateBackground() {
|
||||
updateBackground(props) {
|
||||
// ensure the wider page color always matches the theme
|
||||
window.document.body.style.background = this.props.theme.background;
|
||||
window.document.body.style.background = props.theme.background;
|
||||
}
|
||||
|
||||
@keydown("shift+/")
|
||||
|
||||
@@ -17,7 +17,8 @@ class Mask extends React.Component<Props> {
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
constructor() {
|
||||
super();
|
||||
this.width = randomInteger(75, 100);
|
||||
}
|
||||
|
||||
|
||||
+24
-16
@@ -9,6 +9,7 @@ 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");
|
||||
|
||||
@@ -27,7 +28,8 @@ const GlobalStyles = createGlobalStyle`
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
.ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 12px;
|
||||
box-shadow: 0 -2px 10px ${(props) => props.theme.shadow};
|
||||
@@ -36,13 +38,15 @@ const GlobalStyles = createGlobalStyle`
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal,
|
||||
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + [data-react-modal-body-trap] + .ReactModalPortal {
|
||||
.ReactModal__Overlay {
|
||||
margin-left: 36px;
|
||||
}
|
||||
@@ -72,10 +76,11 @@ const Modal = ({
|
||||
isOpen={isOpen}
|
||||
{...rest}
|
||||
>
|
||||
<Content onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
|
||||
{children}
|
||||
<Content>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
{title && <h1>{title}</h1>}
|
||||
{children}
|
||||
</Centered>
|
||||
</Content>
|
||||
<Back onClick={onRequestClose}>
|
||||
<BackIcon size={32} color="currentColor" />
|
||||
@@ -89,10 +94,20 @@ const Modal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Content = styled(Flex)`
|
||||
const Content = styled(Scrollable)`
|
||||
width: 100%;
|
||||
padding: 8vh 2rem 2rem;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding-top: 13vh;
|
||||
`};
|
||||
`;
|
||||
|
||||
const Centered = styled(Flex)`
|
||||
width: 640px;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
`;
|
||||
|
||||
const StyledModal = styled(ReactModal)`
|
||||
@@ -107,16 +122,9 @@ const StyledModal = styled(ReactModal)`
|
||||
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`
|
||||
@@ -147,7 +155,7 @@ const Close = styled(NudeButton)`
|
||||
`;
|
||||
|
||||
const Back = styled(NudeButton)`
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
display: none;
|
||||
align-items: center;
|
||||
top: 2rem;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -11,13 +10,6 @@ const Button = styled.button`
|
||||
line-height: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
&:focus {
|
||||
transition-duration: 0.05s;
|
||||
box-shadow: ${(props) => lighten(0.4, props.theme.buttonBackground)} 0px 0px
|
||||
0px 3px;
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default React.forwardRef<any, typeof Button>((props, ref) => (
|
||||
|
||||
@@ -42,20 +42,23 @@ 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 && (
|
||||
<Flex>
|
||||
<DocumentTitle>
|
||||
{" "}
|
||||
<StyledGoToIcon /> <Title>{document.title}</Title>
|
||||
</Flex>
|
||||
</DocumentTitle>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const DocumentTitle = styled(Flex)``;
|
||||
|
||||
const Title = styled.span`
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -63,7 +66,7 @@ const Title = styled.span`
|
||||
`;
|
||||
|
||||
const StyledGoToIcon = styled(GoToIcon)`
|
||||
opacity: 0.25;
|
||||
fill: ${(props) => props.theme.divider};
|
||||
`;
|
||||
|
||||
const ResultWrapper = styled.div`
|
||||
@@ -79,13 +82,20 @@ const ResultWrapper = styled.div`
|
||||
const ResultWrapperLink = styled(ResultWrapper.withComponent("a"))`
|
||||
margin: 0 -8px;
|
||||
padding: 8px 4px;
|
||||
border-radius: 8px;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
|
||||
${DocumentTitle} {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,65 +1,60 @@
|
||||
// @flow
|
||||
import { observer, inject } from "mobx-react";
|
||||
import { observer } 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,
|
||||
};
|
||||
|
||||
@observer
|
||||
class Sidebar extends React.Component<Props> {
|
||||
componentWillReceiveProps = (nextProps: Props) => {
|
||||
if (this.props.location !== nextProps.location) {
|
||||
this.props.ui.hideMobileSidebar();
|
||||
function Sidebar({ location, children }: Props) {
|
||||
const { ui } = useStores();
|
||||
const previousLocation = usePrevious(location);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (location !== previousLocation) {
|
||||
ui.hideMobileSidebar();
|
||||
}
|
||||
};
|
||||
}, [ui, location]);
|
||||
|
||||
toggleSidebar = () => {
|
||||
this.props.ui.toggleMobileSidebar();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children, ui } = this.props;
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
const content = (
|
||||
<Container
|
||||
editMode={ui.editMode}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
column
|
||||
>
|
||||
<Toggle
|
||||
onClick={ui.toggleMobileSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
column
|
||||
>
|
||||
<Toggle
|
||||
onClick={this.toggleSidebar}
|
||||
mobileSidebarVisible={ui.mobileSidebarVisible}
|
||||
>
|
||||
{ui.mobileSidebarVisible ? (
|
||||
<CloseIcon size={32} />
|
||||
) : (
|
||||
<MenuIcon size={32} />
|
||||
)}
|
||||
</Toggle>
|
||||
{children}
|
||||
</Container>
|
||||
);
|
||||
{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>;
|
||||
}
|
||||
|
||||
return content;
|
||||
// Fade in the sidebar on first render after page load
|
||||
if (firstRender) {
|
||||
firstRender = false;
|
||||
return <Fade>{content}</Fade>;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const Container = styled(Flex)`
|
||||
@@ -117,4 +112,4 @@ const Toggle = styled.a`
|
||||
`};
|
||||
`;
|
||||
|
||||
export default withRouter(inject("ui")(Sidebar));
|
||||
export default withRouter(observer(Sidebar));
|
||||
|
||||
@@ -10,27 +10,34 @@ import CollectionIcon from "components/CollectionIcon";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Flex from "components/Flex";
|
||||
import DocumentLink from "./DocumentLink";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
collection: Collection,
|
||||
ui: UiStore,
|
||||
canUpdate: boolean,
|
||||
documents: DocumentsStore,
|
||||
activeDocument: ?Document,
|
||||
prefetchDocument: (id: string) => Promise<void>,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class CollectionLink extends React.Component<Props> {
|
||||
@observable menuOpen = false;
|
||||
|
||||
handleTitleChange = async (name: string) => {
|
||||
await this.props.collection.save({ name });
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
collection,
|
||||
documents,
|
||||
activeDocument,
|
||||
prefetchDocument,
|
||||
canUpdate,
|
||||
ui,
|
||||
} = this.props;
|
||||
const expanded = collection.id === ui.activeCollectionId;
|
||||
@@ -49,7 +56,13 @@ class CollectionLink extends React.Component<Props> {
|
||||
expanded={expanded}
|
||||
hideDisclosure
|
||||
menuOpen={this.menuOpen}
|
||||
label={collection.name}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={collection.name}
|
||||
onSubmit={this.handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
exact={false}
|
||||
menu={
|
||||
<CollectionMenu
|
||||
@@ -69,6 +82,7 @@ class CollectionLink extends React.Component<Props> {
|
||||
collection={collection}
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
canUpdate={canUpdate}
|
||||
depth={1.5}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -52,7 +52,7 @@ class Collections extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { collections, ui, documents } = this.props;
|
||||
const { collections, ui, policies, documents } = this.props;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
@@ -63,6 +63,7 @@ class Collections extends React.Component<Props> {
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
canUpdate={policies.abilities(collection.id).update}
|
||||
ui={ui}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -9,19 +9,21 @@ import Document from "models/Document";
|
||||
import DropToImport from "components/DropToImport";
|
||||
import Fade from "components/Fade";
|
||||
import Flex from "components/Flex";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
import SidebarLink from "./SidebarLink";
|
||||
import DocumentMenu from "menus/DocumentMenu";
|
||||
import { type NavigationNode } from "types";
|
||||
|
||||
type Props = {
|
||||
type Props = {|
|
||||
node: NavigationNode,
|
||||
documents: DocumentsStore,
|
||||
canUpdate: boolean,
|
||||
collection?: Collection,
|
||||
activeDocument: ?Document,
|
||||
activeDocumentRef?: (?HTMLElement) => void,
|
||||
prefetchDocument: (documentId: string) => Promise<void>,
|
||||
depth: number,
|
||||
};
|
||||
|};
|
||||
|
||||
@observer
|
||||
class DocumentLink extends React.Component<Props> {
|
||||
@@ -49,6 +51,18 @@ class DocumentLink extends React.Component<Props> {
|
||||
prefetchDocument(node.id);
|
||||
};
|
||||
|
||||
handleTitleChange = async (title: string) => {
|
||||
const document = this.props.documents.get(this.props.node.id);
|
||||
if (!document) return;
|
||||
|
||||
await this.props.documents.update({
|
||||
id: document.id,
|
||||
lastRevision: document.revision,
|
||||
text: document.text,
|
||||
title,
|
||||
});
|
||||
};
|
||||
|
||||
isActiveDocument = () => {
|
||||
return (
|
||||
this.props.activeDocument &&
|
||||
@@ -69,6 +83,7 @@ class DocumentLink extends React.Component<Props> {
|
||||
activeDocumentRef,
|
||||
prefetchDocument,
|
||||
depth,
|
||||
canUpdate,
|
||||
} = this.props;
|
||||
|
||||
const showChildren = !!(
|
||||
@@ -81,6 +96,7 @@ class DocumentLink extends React.Component<Props> {
|
||||
this.isActiveDocument())
|
||||
);
|
||||
const document = documents.get(node.id);
|
||||
const title = node.title || "Untitled";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -96,7 +112,13 @@ class DocumentLink extends React.Component<Props> {
|
||||
state: { title: node.title },
|
||||
}}
|
||||
expanded={showChildren ? true : undefined}
|
||||
label={node.title || "Untitled"}
|
||||
label={
|
||||
<EditableTitle
|
||||
title={title}
|
||||
onSubmit={this.handleTitleChange}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
}
|
||||
depth={depth}
|
||||
exact={false}
|
||||
menuOpen={this.menuOpen}
|
||||
@@ -124,6 +146,7 @@ class DocumentLink extends React.Component<Props> {
|
||||
activeDocument={activeDocument}
|
||||
prefetchDocument={prefetchDocument}
|
||||
depth={depth + 1}
|
||||
canUpdate={canUpdate}
|
||||
/>
|
||||
))}
|
||||
</DocumentChildren>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useStores from "hooks/useStores";
|
||||
|
||||
type Props = {|
|
||||
onSubmit: (title: string) => Promise<void>,
|
||||
title: string,
|
||||
canUpdate: boolean,
|
||||
|};
|
||||
|
||||
function EditableTitle({ title, onSubmit, canUpdate }: Props) {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [originalValue, setOriginalValue] = React.useState(title);
|
||||
const [value, setValue] = React.useState(title);
|
||||
const { ui } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(title);
|
||||
}, [title]);
|
||||
|
||||
const handleChange = React.useCallback((event) => {
|
||||
setValue(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleDoubleClick = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
setValue(originalValue);
|
||||
}
|
||||
},
|
||||
[originalValue]
|
||||
);
|
||||
|
||||
const handleSave = React.useCallback(async () => {
|
||||
setIsEditing(false);
|
||||
|
||||
if (value === originalValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (document) {
|
||||
try {
|
||||
await onSubmit(value);
|
||||
setOriginalValue(value);
|
||||
} catch (error) {
|
||||
setValue(originalValue);
|
||||
ui.showToast(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}, [ui, originalValue, value, onSubmit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
<form onSubmit={handleSave}>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
onBlur={handleSave}
|
||||
autoFocus
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span onDoubleClick={canUpdate ? handleDoubleClick : undefined}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const Input = styled.input`
|
||||
margin-left: -4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
width: calc(100% - 10px);
|
||||
border-radius: 3px;
|
||||
border: 1px solid ${(props) => props.theme.inputBorderFocused};
|
||||
padding: 5px 6px;
|
||||
margin: -4px;
|
||||
height: 32px;
|
||||
|
||||
&:focus {
|
||||
outline-color: ${(props) => props.theme.primary};
|
||||
}
|
||||
`;
|
||||
|
||||
export default EditableTitle;
|
||||
@@ -21,7 +21,7 @@ function HeaderBlock({
|
||||
}: Props) {
|
||||
return (
|
||||
<Header justify="flex-start" align="center" {...rest}>
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} />
|
||||
<TeamLogo alt={`${teamName} logo`} src={logoUrl} size="38px" />
|
||||
<Flex align="flex-start" column>
|
||||
<TeamName showDisclosure>
|
||||
{teamName}{" "}
|
||||
@@ -57,10 +57,16 @@ const TeamName = styled.div`
|
||||
font-size: 16px;
|
||||
`;
|
||||
|
||||
const Header = styled(Flex)`
|
||||
const Header = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding: 16px 24px;
|
||||
position: relative;
|
||||
background: none;
|
||||
line-height: inherit;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import { observable, action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { CollapsedIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -25,79 +24,80 @@ type Props = {
|
||||
depth?: number,
|
||||
};
|
||||
|
||||
@observer
|
||||
class SidebarLink extends React.Component<Props> {
|
||||
@observable expanded: ?boolean = this.props.expanded;
|
||||
function SidebarLink({
|
||||
icon,
|
||||
children,
|
||||
onClick,
|
||||
to,
|
||||
label,
|
||||
active,
|
||||
menu,
|
||||
menuOpen,
|
||||
hideDisclosure,
|
||||
theme,
|
||||
exact,
|
||||
href,
|
||||
depth,
|
||||
...rest
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = React.useState(rest.expanded);
|
||||
|
||||
style = {
|
||||
paddingLeft: `${(this.props.depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
|
||||
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,
|
||||
const style = React.useMemo(() => {
|
||||
return {
|
||||
paddingLeft: `${(depth || 0) * 16 + 16}px`,
|
||||
};
|
||||
}, [depth]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// accounts for whitespace around icon
|
||||
@@ -143,7 +143,6 @@ const StyledNavLink = styled(NavLink)`
|
||||
&:focus {
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.black05};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@@ -171,4 +170,4 @@ const Disclosure = styled(CollapsedIcon)`
|
||||
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
|
||||
`;
|
||||
|
||||
export default withRouter(withTheme(SidebarLink));
|
||||
export default withRouter(withTheme(observer(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 from "socket.io-client";
|
||||
import io, { Socket } from "socket.io-client";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionsStore from "stores/CollectionsStore";
|
||||
import DocumentPresenceStore from "stores/DocumentPresenceStore";
|
||||
@@ -13,6 +13,7 @@ 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();
|
||||
|
||||
@@ -31,9 +32,35 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class SocketProvider extends React.Component<Props> {
|
||||
@observable socket;
|
||||
@observable socket: 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"],
|
||||
@@ -98,6 +125,8 @@ class SocketProvider extends React.Component<Props> {
|
||||
if (document) {
|
||||
document.deletedAt = documentDescriptor.updatedAt;
|
||||
}
|
||||
policies.remove(documentId);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -145,7 +174,21 @@ class SocketProvider extends React.Component<Props> {
|
||||
const collection = collections.get(collectionId) || {};
|
||||
|
||||
if (event.event === "collections.delete") {
|
||||
const collection = collections.get(collectionId);
|
||||
if (collection) {
|
||||
collection.deletedAt = collectionDescriptor.updatedAt;
|
||||
}
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
doc.deletedAt = collectionDescriptor.updatedAt;
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -160,9 +203,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
await collections.fetch(collectionId, { force: true });
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
collections.remove(collectionId);
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -264,14 +308,7 @@ 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 (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @flow
|
||||
import { lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import styled, { withTheme } from "styled-components";
|
||||
@@ -10,7 +9,6 @@ type Props = {
|
||||
|
||||
const StyledNavLink = styled(NavLink)`
|
||||
position: relative;
|
||||
bottom: -1px;
|
||||
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
@@ -24,13 +22,6 @@ const StyledNavLink = styled(NavLink)`
|
||||
border-bottom: 3px solid ${(props) => props.theme.divider};
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom: 3px solid
|
||||
${(props) => lighten(0.4, props.theme.buttonBackground)};
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
`;
|
||||
|
||||
function Tab({ theme, ...rest }: Props) {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
const TeamLogo = styled.img`
|
||||
width: auto;
|
||||
height: 38px;
|
||||
width: ${(props) => props.size || "auto"};
|
||||
height: ${(props) => props.size || "38px"};
|
||||
border-radius: 4px;
|
||||
background: ${(props) => props.theme.background};
|
||||
border: 1px solid ${(props) => props.theme.divider};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
export default TeamLogo;
|
||||
|
||||
+17
-1
@@ -23,6 +23,9 @@ function eachMinute(fn) {
|
||||
type Props = {
|
||||
dateTime: string,
|
||||
children?: React.Node,
|
||||
tooltipDelay?: number,
|
||||
addSuffix?: boolean,
|
||||
shorten?: boolean,
|
||||
};
|
||||
|
||||
class Time extends React.Component<Props> {
|
||||
@@ -39,13 +42,26 @@ class Time extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { shorten, addSuffix } = this.props;
|
||||
let content = distanceInWordsToNow(this.props.dateTime, {
|
||||
addSuffix,
|
||||
});
|
||||
|
||||
if (shorten) {
|
||||
content = content
|
||||
.replace("about", "")
|
||||
.replace("less than a minute ago", "just now")
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
tooltip={format(this.props.dateTime, "MMMM Do, YYYY h:mm a")}
|
||||
delay={this.props.tooltipDelay}
|
||||
placement="bottom"
|
||||
>
|
||||
<time dateTime={this.props.dateTime}>
|
||||
{this.props.children || distanceInWordsToNow(this.props.dateTime)}
|
||||
{this.props.children || content}
|
||||
</time>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class Abstract extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://app.goabstract.com/embed/${shareId}`}
|
||||
title={`Abstract (${shareId})`}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ export default class Airtable extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://airtable.com/embed/${shareId}`}
|
||||
title={`Airtable (${shareId})`}
|
||||
border
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
|},
|
||||
|};
|
||||
|
||||
export default class ClickUp extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href}
|
||||
title="ClickUp Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||
import ClickUp from "./ClickUp";
|
||||
|
||||
describe("ClickUp", () => {
|
||||
const match = ClickUp.ENABLED[0];
|
||||
test("to be enabled on share link", () => {
|
||||
expect(
|
||||
"https://share.clickup.com/b/h/6-9310960-2/c9d837d74182317".match(match)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://share.clickup.com".match(match)).toBe(null);
|
||||
expect("https://clickup.com/".match(match)).toBe(null);
|
||||
expect("https://clickup.com/features".match(match)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,6 @@ export default class Codepen extends React.Component<Props> {
|
||||
render() {
|
||||
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
|
||||
|
||||
return <Frame src={normalizedUrl} title="Codepen Embed" />;
|
||||
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default class Figma extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
|
||||
title="Figma Embed"
|
||||
border
|
||||
|
||||
@@ -15,6 +15,13 @@ export default class Framer extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.attrs.href} title="Framer Embed" border />;
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href}
|
||||
title="Framer Embed"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const URL_REGEX = new RegExp(
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
isSelected: boolean,
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
@@ -48,6 +49,7 @@ class Gist extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
|
||||
ref={this.updateIframeContent}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://docs.google.com/document/d/(.*)/pub(.*)$"
|
||||
);
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
@@ -18,7 +16,21 @@ export default class GoogleDocs extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame src={this.props.attrs.href} title="Google Docs Embed" border />
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<img
|
||||
src="/images/google-docs.png"
|
||||
alt="Google Docs Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
title="Google Docs"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,19 @@ describe("GoogleDocs", () => {
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/edit".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/preview".match(
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
expect("https://docs.google.com/document".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://docs.google.com/spreadsheets/d/(.*)/pub(.*)$"
|
||||
);
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
@@ -18,7 +16,21 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Frame src={this.props.attrs.href} title="Google Sheets Embed" border />
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href.replace("/edit", "/preview")}
|
||||
icon={
|
||||
<img
|
||||
src="/images/google-sheets.png"
|
||||
alt="Google Sheets Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
title="Google Sheets"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ describe("GoogleSheets", () => {
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://docs.google.com/spreadsheets".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import * as React from "react";
|
||||
import Frame from "./components/Frame";
|
||||
|
||||
const URL_REGEX = new RegExp(
|
||||
"^https?://docs.google.com/presentation/d/(.*)/pub(.*)$"
|
||||
);
|
||||
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
|
||||
|
||||
type Props = {|
|
||||
attrs: {|
|
||||
@@ -19,8 +17,20 @@ export default class GoogleSlides extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<Frame
|
||||
src={this.props.attrs.href.replace("/pub", "/embed")}
|
||||
title="Google Slides Embed"
|
||||
{...this.props}
|
||||
src={this.props.attrs.href
|
||||
.replace("/edit", "/preview")
|
||||
.replace("/pub", "/embed")}
|
||||
icon={
|
||||
<img
|
||||
src="/images/google-slides.png"
|
||||
alt="Google Slides Icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
}
|
||||
canonicalUrl={this.props.attrs.href}
|
||||
title="Google Slides"
|
||||
border
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,14 +14,14 @@ describe("GoogleSlides", () => {
|
||||
match
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect(
|
||||
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
|
||||
match
|
||||
)
|
||||
).toBe(null);
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("to not be enabled elsewhere", () => {
|
||||
expect("https://docs.google.com/presentation".match(match)).toBe(null);
|
||||
expect("https://docs.google.com".match(match)).toBe(null);
|
||||
expect("https://www.google.com".match(match)).toBe(null);
|
||||
|
||||
@@ -12,6 +12,7 @@ const IMAGE_REGEX = new RegExp(
|
||||
);
|
||||
|
||||
type Props = {|
|
||||
isSelected: boolean,
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
@@ -25,6 +26,7 @@ export default class InVision extends React.Component<Props> {
|
||||
if (IMAGE_REGEX.test(this.props.attrs.href)) {
|
||||
return (
|
||||
<ImageZoom
|
||||
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
|
||||
image={{
|
||||
src: this.props.attrs.href,
|
||||
alt: "InVision Embed",
|
||||
@@ -37,6 +39,12 @@ export default class InVision extends React.Component<Props> {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Frame src={this.props.attrs.href} title="InVision Embed" />;
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href}
|
||||
title="InVision Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,6 +17,6 @@ export default class Loom extends React.Component<Props> {
|
||||
render() {
|
||||
const normalizedUrl = this.props.attrs.href.replace("share", "embed");
|
||||
|
||||
return <Frame src={normalizedUrl} title="Loom Embed" />;
|
||||
return <Frame {...this.props} src={normalizedUrl} title="Loom Embed" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default class Lucidchart extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
|
||||
title="Lucidchart Embed"
|
||||
/>
|
||||
|
||||
@@ -15,6 +15,13 @@ export default class Marvel extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.attrs.href} title="Marvel Embed" border />;
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href}
|
||||
title="Marvel Embed"
|
||||
border
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class Mindmeister extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
|
||||
title="Mindmeister Embed"
|
||||
border
|
||||
|
||||
@@ -20,6 +20,7 @@ export default class RealtimeBoard extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://realtimeboard.com/app/embed/${boardId}`}
|
||||
title={`RealtimeBoard (${boardId})`}
|
||||
/>
|
||||
|
||||
@@ -21,7 +21,11 @@ export default class ModeAnalytics extends React.Component<Props> {
|
||||
const normalizedUrl = this.props.attrs.href.replace(/\/embed$/, "");
|
||||
|
||||
return (
|
||||
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`${normalizedUrl}/embed`}
|
||||
title="Mode Analytics Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,6 +17,8 @@ export default class Prezi extends React.Component<Props> {
|
||||
render() {
|
||||
const url = this.props.attrs.href.replace(/\/embed$/, "");
|
||||
|
||||
return <Frame src={`${url}/embed`} title="Prezi Embed" border />;
|
||||
return (
|
||||
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export default class Spotify extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
width="300px"
|
||||
height="380px"
|
||||
src={`https://open.spotify.com/embed${normalizedPath}`}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default class Trello extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
width="248px"
|
||||
height="185px"
|
||||
src={`https://trello.com/embed/board?id=${objectId}`}
|
||||
|
||||
@@ -17,6 +17,12 @@ export default class Typeform extends React.Component<Props> {
|
||||
static ENABLED = [URL_REGEX];
|
||||
|
||||
render() {
|
||||
return <Frame src={this.props.attrs.href} title="Typeform Embed" />;
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={this.props.attrs.href}
|
||||
title="Typeform Embed"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export default class Vimeo extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
|
||||
title={`Vimeo Embed (${videoId})`}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import Frame from "./components/Frame";
|
||||
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
|
||||
|
||||
type Props = {|
|
||||
isSelected: boolean,
|
||||
attrs: {|
|
||||
href: string,
|
||||
matches: string[],
|
||||
@@ -20,6 +21,7 @@ export default class YouTube extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<Frame
|
||||
{...this.props}
|
||||
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
|
||||
title={`YouTube (${videoId})`}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// @flow
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { OpenIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Flex from "components/Flex";
|
||||
|
||||
type Props = {
|
||||
src?: string,
|
||||
border?: boolean,
|
||||
title?: string,
|
||||
icon?: React.Node,
|
||||
canonicalUrl?: string,
|
||||
isSelected?: boolean,
|
||||
width?: string,
|
||||
height?: string,
|
||||
};
|
||||
@@ -40,15 +46,26 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
width = "100%",
|
||||
height = "400px",
|
||||
forwardedRef,
|
||||
...rest
|
||||
icon,
|
||||
title,
|
||||
canonicalUrl,
|
||||
isSelected,
|
||||
src,
|
||||
} = this.props;
|
||||
const Component = border ? StyledIframe : "iframe";
|
||||
const withBar = !!(icon || canonicalUrl);
|
||||
|
||||
return (
|
||||
<Rounded width={width} height={height}>
|
||||
<Rounded
|
||||
width={width}
|
||||
height={height}
|
||||
withBar={withBar}
|
||||
className={isSelected ? "ProseMirror-selectednode" : ""}
|
||||
>
|
||||
{this.isLoaded && (
|
||||
<Component
|
||||
ref={forwardedRef}
|
||||
withBar={withBar}
|
||||
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -56,20 +73,60 @@ class Frame extends React.Component<PropsWithRef> {
|
||||
frameBorder="0"
|
||||
title="embed"
|
||||
loading="lazy"
|
||||
src={src}
|
||||
allowFullScreen
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{withBar && (
|
||||
<Bar align="center">
|
||||
{icon} <Title>{title}</Title>
|
||||
{canonicalUrl && (
|
||||
<Open
|
||||
href={canonicalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<OpenIcon color="currentColor" size={18} /> Open
|
||||
</Open>
|
||||
)}
|
||||
</Bar>
|
||||
)}
|
||||
</Rounded>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Rounded = styled.div`
|
||||
border-radius: 3px;
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
overflow: hidden;
|
||||
width: ${(props) => props.width};
|
||||
height: ${(props) => props.height};
|
||||
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
|
||||
`;
|
||||
|
||||
const Open = styled.a`
|
||||
color: ${(props) => props.theme.textSecondary} !important;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
const Title = styled.span`
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding-left: 4px;
|
||||
`;
|
||||
|
||||
const Bar = styled(Flex)`
|
||||
background: ${(props) => props.theme.secondaryBackground};
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
padding: 0 8px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
|
||||
@@ -79,7 +136,8 @@ const Iframe = (props) => <iframe {...props} />;
|
||||
const StyledIframe = styled(Iframe)`
|
||||
border: 1px solid;
|
||||
border-color: ${(props) => props.theme.embedBorder};
|
||||
border-radius: 3px;
|
||||
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
|
||||
display: block;
|
||||
`;
|
||||
|
||||
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import Abstract from "./Abstract";
|
||||
import Airtable from "./Airtable";
|
||||
import ClickUp from "./ClickUp";
|
||||
import Codepen from "./Codepen";
|
||||
import Figma from "./Figma";
|
||||
import Framer from "./Framer";
|
||||
@@ -57,6 +58,13 @@ export default [
|
||||
component: Airtable,
|
||||
matcher: matcher(Airtable),
|
||||
},
|
||||
{
|
||||
title: "ClickUp",
|
||||
keywords: "project",
|
||||
icon: () => <Img src="/images/clickup.png" />,
|
||||
component: ClickUp,
|
||||
matcher: matcher(ClickUp),
|
||||
},
|
||||
{
|
||||
title: "Codepen",
|
||||
keywords: "code editor",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
|
||||
export default function usePrevious(value: any) {
|
||||
const ref = React.useRef();
|
||||
React.useEffect(() => {
|
||||
ref.current = value;
|
||||
});
|
||||
return ref.current;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// @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);
|
||||
}
|
||||
+16
-22
@@ -1,4 +1,6 @@
|
||||
// @flow
|
||||
import "mobx-react-lite/batchingForReactDom";
|
||||
import "focus-visible";
|
||||
import { Provider } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { render } from "react-dom";
|
||||
@@ -12,32 +14,24 @@ 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>
|
||||
{DevTools && <DevTools position={{ bottom: 0, right: 0 }} />}
|
||||
</>,
|
||||
<ErrorBoundary>
|
||||
<Provider {...stores}>
|
||||
<Theme>
|
||||
<Router>
|
||||
<>
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
</>
|
||||
</Router>
|
||||
</Theme>
|
||||
</Provider>
|
||||
</ErrorBoundary>,
|
||||
element
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import Modal from "components/Modal";
|
||||
import VisuallyHidden from "components/VisuallyHidden";
|
||||
import getDataTransferFiles from "utils/getDataTransferFiles";
|
||||
import importFile from "utils/importFile";
|
||||
import { newDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -55,11 +54,13 @@ class CollectionMenu extends React.Component<Props> {
|
||||
const files = getDataTransferFiles(ev);
|
||||
|
||||
try {
|
||||
const document = await importFile({
|
||||
file: files[0],
|
||||
documents: this.props.documents,
|
||||
collectionId: this.props.collection.id,
|
||||
});
|
||||
const file = files[0];
|
||||
const document = await this.props.documents.import(
|
||||
file,
|
||||
null,
|
||||
this.props.collection.id,
|
||||
{ publish: true }
|
||||
);
|
||||
this.props.history.push(document.url);
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
@@ -103,7 +104,14 @@ class CollectionMenu extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { policies, collection, position, onOpen, onClose } = this.props;
|
||||
const {
|
||||
policies,
|
||||
documents,
|
||||
collection,
|
||||
position,
|
||||
onOpen,
|
||||
onClose,
|
||||
} = this.props;
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
@@ -114,7 +122,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||
ref={(ref) => (this.file = ref)}
|
||||
onChange={this.onFilePicked}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
accept="text/markdown, text/plain"
|
||||
accept={documents.importFileTypes.join(", ")}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
|
||||
@@ -127,6 +135,7 @@ 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}>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionStore from "stores/CollectionsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
@@ -12,13 +11,18 @@ import Document from "models/Document";
|
||||
import DocumentDelete from "scenes/DocumentDelete";
|
||||
import DocumentShare from "scenes/DocumentShare";
|
||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||
import { DropdownMenu, DropdownMenuItem } from "components/DropdownMenu";
|
||||
import CollectionIcon from "components/CollectionIcon";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
Header,
|
||||
} from "components/DropdownMenu";
|
||||
import Modal from "components/Modal";
|
||||
import {
|
||||
documentUrl,
|
||||
documentMoveUrl,
|
||||
editDocumentUrl,
|
||||
documentHistoryUrl,
|
||||
documentMoveUrl,
|
||||
documentUrl,
|
||||
editDocumentUrl,
|
||||
newDocumentUrl,
|
||||
} from "utils/routeHelpers";
|
||||
|
||||
@@ -34,6 +38,7 @@ type Props = {
|
||||
showPrint?: boolean,
|
||||
showToggleEmbeds?: boolean,
|
||||
showPin?: boolean,
|
||||
label?: React.Node,
|
||||
onOpen?: () => void,
|
||||
onClose?: () => void,
|
||||
};
|
||||
@@ -101,11 +106,19 @@ class DocumentMenu extends React.Component<Props> {
|
||||
this.props.ui.showToast("Document archived");
|
||||
};
|
||||
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.restore();
|
||||
handleRestore = async (
|
||||
ev: SyntheticEvent<>,
|
||||
options?: { collectionId: string }
|
||||
) => {
|
||||
await this.props.document.restore(options);
|
||||
this.props.ui.showToast("Document restored");
|
||||
};
|
||||
|
||||
handleUnpublish = async (ev: SyntheticEvent<>) => {
|
||||
await this.props.document.unpublish();
|
||||
this.props.ui.showToast("Document unpublished");
|
||||
};
|
||||
|
||||
handlePin = (ev: SyntheticEvent<>) => {
|
||||
this.props.document.pin();
|
||||
};
|
||||
@@ -150,6 +163,8 @@ class DocumentMenu extends React.Component<Props> {
|
||||
showPrint,
|
||||
showPin,
|
||||
auth,
|
||||
collections,
|
||||
label,
|
||||
onOpen,
|
||||
onClose,
|
||||
} = this.props;
|
||||
@@ -157,6 +172,7 @@ class DocumentMenu extends React.Component<Props> {
|
||||
const can = policies.abilities(document.id);
|
||||
const canShareDocuments = can.share && auth.team && auth.team.sharing;
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const collection = collections.get(document.collectionId);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -165,12 +181,47 @@ class DocumentMenu extends React.Component<Props> {
|
||||
position={position}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
label={label}
|
||||
>
|
||||
{(can.unarchive || can.restore) && (
|
||||
{can.unarchive && (
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.restore &&
|
||||
(collection ? (
|
||||
<DropdownMenuItem onClick={this.handleRestore}>
|
||||
Restore
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
label={<DropdownMenuItem>Restore…</DropdownMenuItem>}
|
||||
style={{
|
||||
left: -170,
|
||||
position: "relative",
|
||||
top: -40,
|
||||
}}
|
||||
hover
|
||||
>
|
||||
<Header>Choose a collection</Header>
|
||||
{collections.orderedData.map((collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={collection.id}
|
||||
onClick={(ev) =>
|
||||
this.handleRestore(ev, { collectionId: collection.id })
|
||||
}
|
||||
disabled={!can.update}
|
||||
>
|
||||
<CollectionIcon collection={collection} />
|
||||
{collection.name}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenu>
|
||||
))}
|
||||
{showPin &&
|
||||
(document.pinned
|
||||
? can.unpin && (
|
||||
@@ -230,6 +281,11 @@ class DocumentMenu extends React.Component<Props> {
|
||||
Create template…
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.unpublish && (
|
||||
<DropdownMenuItem onClick={this.handleUnpublish}>
|
||||
Unpublish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{can.update && (
|
||||
<DropdownMenuItem onClick={this.handleEdit}>Edit</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ type Props = {
|
||||
class RevisionMenu extends React.Component<Props> {
|
||||
handleRestore = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
await this.props.document.restore(this.props.revision);
|
||||
await this.props.document.restore({ revisionId: this.props.revision.id });
|
||||
this.props.ui.showToast("Document restored");
|
||||
this.props.history.push(this.props.document.url);
|
||||
};
|
||||
|
||||
@@ -31,10 +31,15 @@ class ShareMenu extends React.Component<Props> {
|
||||
this.redirectTo = this.props.share.documentUrl;
|
||||
};
|
||||
|
||||
handleRevoke = (ev: SyntheticEvent<>) => {
|
||||
handleRevoke = async (ev: SyntheticEvent<>) => {
|
||||
ev.preventDefault();
|
||||
this.props.shares.revoke(this.props.share);
|
||||
this.props.ui.showToast("Share link revoked");
|
||||
|
||||
try {
|
||||
await this.props.shares.revoke(this.props.share);
|
||||
this.props.ui.showToast("Share link revoked");
|
||||
} catch (err) {
|
||||
this.props.ui.showToast(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
handleCopy = () => {
|
||||
|
||||
+25
-7
@@ -1,13 +1,14 @@
|
||||
// @flow
|
||||
import addDays from "date-fns/add_days";
|
||||
import differenceInDays from "date-fns/difference_in_days";
|
||||
import invariant from "invariant";
|
||||
import { action, set, observable, computed } from "mobx";
|
||||
import { action, computed, observable, set } from "mobx";
|
||||
import parseTitle from "shared/utils/parseTitle";
|
||||
import unescape from "shared/utils/unescape";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import BaseModel from "models/BaseModel";
|
||||
import Revision from "models/Revision";
|
||||
import User from "models/User";
|
||||
import View from "./View";
|
||||
|
||||
type SaveOptions = {
|
||||
publish?: boolean,
|
||||
@@ -20,11 +21,11 @@ export default class Document extends BaseModel {
|
||||
@observable isSaving: boolean = false;
|
||||
@observable embedsDisabled: boolean = false;
|
||||
@observable injectTemplate: boolean = false;
|
||||
@observable lastViewedAt: ?string;
|
||||
store: DocumentsStore;
|
||||
|
||||
collaborators: User[];
|
||||
collectionId: string;
|
||||
lastViewedAt: ?string;
|
||||
createdAt: string;
|
||||
createdBy: User;
|
||||
updatedAt: string;
|
||||
@@ -48,7 +49,7 @@ export default class Document extends BaseModel {
|
||||
constructor(fields: Object, store: DocumentsStore) {
|
||||
super(fields, store);
|
||||
|
||||
if (this.isNew && this.isFromTemplate) {
|
||||
if (this.isNewDocument && this.isFromTemplate) {
|
||||
this.title = "";
|
||||
}
|
||||
}
|
||||
@@ -73,6 +74,14 @@ export default class Document extends BaseModel {
|
||||
return !!this.lastViewedAt && this.lastViewedAt < this.updatedAt;
|
||||
}
|
||||
|
||||
@computed
|
||||
get isNew(): boolean {
|
||||
return (
|
||||
!this.lastViewedAt &&
|
||||
differenceInDays(new Date(), new Date(this.createdAt)) < 14
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isStarred(): boolean {
|
||||
return !!this.store.starredIds.get(this.id);
|
||||
@@ -113,7 +122,7 @@ export default class Document extends BaseModel {
|
||||
}
|
||||
|
||||
@computed
|
||||
get isNew(): boolean {
|
||||
get isNewDocument(): boolean {
|
||||
return this.createdAt === this.updatedAt;
|
||||
}
|
||||
|
||||
@@ -141,8 +150,12 @@ export default class Document extends BaseModel {
|
||||
return this.store.archive(this);
|
||||
};
|
||||
|
||||
restore = (revision: Revision) => {
|
||||
return this.store.restore(this, revision);
|
||||
restore = (options) => {
|
||||
return this.store.restore(this, options);
|
||||
};
|
||||
|
||||
unpublish = () => {
|
||||
return this.store.unpublish(this);
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -196,6 +209,11 @@ export default class Document extends BaseModel {
|
||||
return this.store.rootStore.views.create({ documentId: this.id });
|
||||
};
|
||||
|
||||
@action
|
||||
updateLastViewed = (view: View) => {
|
||||
this.lastViewedAt = view.lastViewedAt;
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = async () => {
|
||||
return this.store.templatize(this.id);
|
||||
|
||||
+29
-12
@@ -36,6 +36,7 @@ import Tab from "components/Tab";
|
||||
import Tabs from "components/Tabs";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import CollectionMenu from "menus/CollectionMenu";
|
||||
import { AuthorizationError } from "utils/errors";
|
||||
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -62,10 +63,19 @@ class CollectionScene extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const { id } = nextProps.match.params;
|
||||
componentDidUpdate(prevProps) {
|
||||
const { id } = this.props.match.params;
|
||||
|
||||
if (id && id !== this.props.match.params.id) {
|
||||
if (this.collection) {
|
||||
const { collection } = this;
|
||||
const policy = this.props.policies.get(collection.id);
|
||||
|
||||
if (!policy) {
|
||||
this.loadContent(collection.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (id && id !== prevProps.match.params.id) {
|
||||
this.loadContent(id);
|
||||
}
|
||||
}
|
||||
@@ -75,18 +85,24 @@ class CollectionScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
loadContent = async (id: string) => {
|
||||
const collection = await this.props.collections.fetch(id);
|
||||
try {
|
||||
const collection = await this.props.collections.fetch(id);
|
||||
|
||||
if (collection) {
|
||||
this.props.ui.setActiveCollection(collection);
|
||||
this.collection = collection;
|
||||
if (collection) {
|
||||
this.props.ui.setActiveCollection(collection);
|
||||
this.collection = collection;
|
||||
|
||||
await this.props.documents.fetchPinned({
|
||||
collectionId: id,
|
||||
});
|
||||
await this.props.documents.fetchPinned({
|
||||
collectionId: id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AuthorizationError) {
|
||||
this.collection = null;
|
||||
}
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
|
||||
this.isFetching = false;
|
||||
};
|
||||
|
||||
onNewDocument = (ev: SyntheticEvent<>) => {
|
||||
@@ -124,6 +140,7 @@ class CollectionScene extends React.Component<Props> {
|
||||
<>
|
||||
<Action>
|
||||
<InputSearch
|
||||
source="collection"
|
||||
placeholder="Search in collection…"
|
||||
collectionId={match.params.id}
|
||||
/>
|
||||
|
||||
@@ -46,8 +46,9 @@ class CollectionDelete extends React.Component<Props> {
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<HelpText>
|
||||
Are you sure about that? Deleting the{" "}
|
||||
<strong>{collection.name}</strong> collection is permanent and will
|
||||
also delete all of the documents within it, so be extra careful.
|
||||
<strong>{collection.name}</strong> collection is permanent and
|
||||
cannot be restored, however documents within will be moved to the
|
||||
trash.
|
||||
</HelpText>
|
||||
<Button type="submit" disabled={this.isDeleting} autoFocus danger>
|
||||
{this.isDeleting ? "Deleting…" : "I’m sure – Delete"}
|
||||
|
||||
@@ -20,20 +20,12 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class CollectionEdit extends React.Component<Props> {
|
||||
@observable name: string;
|
||||
@observable description: string = "";
|
||||
@observable icon: string = "";
|
||||
@observable color: string = "#4E5C6E";
|
||||
@observable name: string = this.props.collection.name;
|
||||
@observable description: string = this.props.collection.description;
|
||||
@observable icon: string = this.props.collection.icon;
|
||||
@observable color: string = this.props.collection.color || "#4E5C6E";
|
||||
@observable private: boolean = this.props.collection.private;
|
||||
@observable isSaving: boolean;
|
||||
@observable private: boolean = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.name = this.props.collection.name;
|
||||
this.description = this.props.collection.description;
|
||||
this.icon = this.props.collection.icon;
|
||||
this.color = this.props.collection.color;
|
||||
this.private = this.props.collection.private;
|
||||
}
|
||||
|
||||
handleSubmit = async (ev: SyntheticEvent<*>) => {
|
||||
ev.preventDefault();
|
||||
|
||||
@@ -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 its visibility
|
||||
changing the visibility
|
||||
</a>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
@@ -67,7 +67,7 @@ class Dashboard extends React.Component<Props> {
|
||||
</Switch>
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch />
|
||||
<InputSearch source="dashboard" />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @flow
|
||||
import distanceInWordsToNow from "date-fns/distance_in_words_to_now";
|
||||
import invariant from "invariant";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer, inject } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import type { RouterHistory, Match } from "react-router-dom";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import parseDocumentSlug from "shared/utils/parseDocumentSlug";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import PoliciesStore from "stores/PoliciesStore";
|
||||
import RevisionsStore from "stores/RevisionsStore";
|
||||
@@ -20,6 +23,7 @@ import Loading from "./Loading";
|
||||
import SocketPresence from "./SocketPresence";
|
||||
import { type LocationWithState } from "types";
|
||||
import { NotFoundError, OfflineError } from "utils/errors";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {|
|
||||
@@ -50,7 +54,8 @@ class DataLoader extends React.Component<Props> {
|
||||
// reload from the server otherwise the UI will not know which authorizations
|
||||
// the user has
|
||||
if (this.document) {
|
||||
const policy = this.props.policies.get(this.document.id);
|
||||
const document = this.document;
|
||||
const policy = this.props.policies.get(document.id);
|
||||
|
||||
if (!policy && !this.error) {
|
||||
this.loadDocument();
|
||||
@@ -69,14 +74,50 @@ class DataLoader extends React.Component<Props> {
|
||||
}
|
||||
|
||||
onSearchLink = async (term: string) => {
|
||||
const results = await this.props.documents.search(term);
|
||||
if (isInternalUrl(term)) {
|
||||
// search for exact internal document
|
||||
const slug = parseDocumentSlug(term);
|
||||
try {
|
||||
const document = await this.props.documents.fetch(slug);
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
addSuffix: true,
|
||||
});
|
||||
return [
|
||||
{
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
// NotFoundError could not find document for slug
|
||||
if (!(error instanceof NotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
.filter((result) => result.document.title)
|
||||
.map((result) => ({
|
||||
title: result.document.title,
|
||||
url: result.document.url,
|
||||
}));
|
||||
// default search for anything that doesn't look like a URL
|
||||
const results = await this.props.documents.searchTitles(term);
|
||||
|
||||
return sortBy(
|
||||
results.map((document) => {
|
||||
const time = distanceInWordsToNow(document.updatedAt, {
|
||||
addSuffix: true,
|
||||
});
|
||||
return {
|
||||
title: document.title,
|
||||
subtitle: `Updated ${time}`,
|
||||
url: document.url,
|
||||
};
|
||||
}),
|
||||
(document) =>
|
||||
deburr(document.title)
|
||||
.toLowerCase()
|
||||
.startsWith(deburr(term).toLowerCase())
|
||||
? -1
|
||||
: 1
|
||||
);
|
||||
};
|
||||
|
||||
onCreateLink = async (title: string) => {
|
||||
@@ -101,6 +142,11 @@ class DataLoader extends React.Component<Props> {
|
||||
loadDocument = async () => {
|
||||
const { shareId, documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// sets the document as active in the sidebar if we already have it loaded
|
||||
if (this.document) {
|
||||
this.props.ui.setActiveDocument(this.document);
|
||||
}
|
||||
|
||||
try {
|
||||
this.document = await this.props.documents.fetch(documentSlug, {
|
||||
shareId,
|
||||
|
||||
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -67,23 +68,17 @@ type Props = {
|
||||
|
||||
@observer
|
||||
class DocumentScene extends React.Component<Props> {
|
||||
@observable editor: ?any;
|
||||
@observable editor = React.createRef();
|
||||
@observable isUploading: boolean = false;
|
||||
@observable isSaving: boolean = false;
|
||||
@observable isPublishing: boolean = false;
|
||||
@observable isDirty: boolean = false;
|
||||
@observable isEmpty: boolean = true;
|
||||
@observable moveModalOpen: boolean = false;
|
||||
@observable lastRevision: number;
|
||||
@observable title: string;
|
||||
@observable lastRevision: number = this.props.document.revision;
|
||||
@observable title: string = this.props.document.title;
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
constructor(props) {
|
||||
super();
|
||||
this.title = props.document.title;
|
||||
this.lastRevision = props.document.revision;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.updateIsDirty();
|
||||
this.updateBackground();
|
||||
@@ -94,6 +89,10 @@ class DocumentScene extends React.Component<Props> {
|
||||
|
||||
if (this.props.readOnly) {
|
||||
this.lastRevision = document.revision;
|
||||
|
||||
if (document.title !== this.title) {
|
||||
this.title = document.title;
|
||||
}
|
||||
} else if (prevProps.document.revision !== this.lastRevision) {
|
||||
if (auth.user && document.updatedBy.id !== auth.user.id) {
|
||||
this.props.ui.showToast(
|
||||
@@ -112,9 +111,9 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
|
||||
if (document.injectTemplate) {
|
||||
this.isDirty = true;
|
||||
this.title = document.title;
|
||||
document.injectTemplate = false;
|
||||
this.title = document.title;
|
||||
this.isDirty = true;
|
||||
}
|
||||
|
||||
this.updateBackground();
|
||||
@@ -133,7 +132,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
ev.preventDefault();
|
||||
const { document, abilities } = this.props;
|
||||
|
||||
if (abilities.update) {
|
||||
if (abilities.move) {
|
||||
this.props.history.push(documentMoveUrl(document));
|
||||
}
|
||||
}
|
||||
@@ -380,7 +379,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
tocVisible={ui.tocVisible}
|
||||
tocVisible={ui.tocVisible && readOnly}
|
||||
column
|
||||
auto
|
||||
>
|
||||
@@ -412,50 +411,52 @@ class DocumentScene extends React.Component<Props> {
|
||||
)}
|
||||
</Notice>
|
||||
)}
|
||||
<Flex auto={!readOnly}>
|
||||
{ui.tocVisible && readOnly && (
|
||||
<Contents
|
||||
headings={this.editor ? this.editor.getHeadings() : []}
|
||||
<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>
|
||||
{readOnly && !isShare && !revision && (
|
||||
<>
|
||||
<MarkAsViewed document={document} />
|
||||
<ReferencesWrapper isOnlyTitle={document.isOnlyTitle}>
|
||||
<References document={document} />
|
||||
</ReferencesWrapper>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</React.Suspense>
|
||||
</MaxWidth>
|
||||
</Container>
|
||||
</Background>
|
||||
|
||||
@@ -13,13 +13,11 @@ import DocumentsStore from "stores/DocumentsStore";
|
||||
import UiStore from "stores/UiStore";
|
||||
import Document from "models/Document";
|
||||
import Flex from "components/Flex";
|
||||
import Input from "components/Input";
|
||||
import { Outline } 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,
|
||||
@@ -36,14 +34,19 @@ class DocumentMove extends React.Component<Props> {
|
||||
|
||||
@computed
|
||||
get searchIndex() {
|
||||
const { collections } = this.props;
|
||||
const { collections, documents } = this.props;
|
||||
const paths = collections.pathsToDocuments;
|
||||
const index = new Search("id");
|
||||
index.addIndex("title");
|
||||
|
||||
// Build index
|
||||
const indexeableDocuments = [];
|
||||
paths.forEach((path) => indexeableDocuments.push(path));
|
||||
paths.forEach((path) => {
|
||||
const doc = documents.get(path.id);
|
||||
if (!doc || !doc.isTemplate) {
|
||||
indexeableDocuments.push(path);
|
||||
}
|
||||
});
|
||||
index.addDocuments(indexeableDocuments);
|
||||
|
||||
return index;
|
||||
@@ -52,6 +55,7 @@ class DocumentMove extends React.Component<Props> {
|
||||
@computed
|
||||
get results(): DocumentPath[] {
|
||||
const { document, collections } = this.props;
|
||||
const onlyShowCollections = document.isTemplate;
|
||||
|
||||
let results = [];
|
||||
if (collections.isLoaded) {
|
||||
@@ -62,17 +66,23 @@ class DocumentMove extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude root from search results if document is already at the root
|
||||
if (!document.parentDocumentId) {
|
||||
results = results.filter((result) => result.id !== document.collectionId);
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
@@ -129,35 +139,41 @@ class DocumentMove extends React.Component<Props> {
|
||||
</Section>
|
||||
|
||||
<Section column>
|
||||
<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>
|
||||
<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>
|
||||
</Section>
|
||||
</Flex>
|
||||
)}
|
||||
@@ -166,6 +182,37 @@ 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;
|
||||
`;
|
||||
|
||||
@@ -11,7 +11,6 @@ import DocumentMetaWithViews from "components/DocumentMetaWithViews";
|
||||
import Editor from "components/Editor";
|
||||
import Flex from "components/Flex";
|
||||
import HoverPreview from "components/HoverPreview";
|
||||
import LoadingPlaceholder from "components/LoadingPlaceholder";
|
||||
import { documentHistoryUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -22,37 +21,55 @@ type Props = {
|
||||
isDraft: boolean,
|
||||
isShare: boolean,
|
||||
readOnly?: boolean,
|
||||
onSave: ({ publish?: boolean, done?: boolean, autosave?: boolean }) => mixed,
|
||||
innerRef: { current: any },
|
||||
};
|
||||
|
||||
@observer
|
||||
class DocumentEditor extends React.Component<Props> {
|
||||
@observable activeLinkEvent: ?MouseEvent;
|
||||
editor = React.createRef<any>();
|
||||
|
||||
focusAtStart = () => {
|
||||
if (this.editor.current) {
|
||||
this.editor.current.focusAtStart();
|
||||
if (this.props.innerRef.current) {
|
||||
this.props.innerRef.current.focusAtStart();
|
||||
}
|
||||
};
|
||||
|
||||
focusAtEnd = () => {
|
||||
if (this.editor.current) {
|
||||
this.editor.current.focusAtEnd();
|
||||
if (this.props.innerRef.current) {
|
||||
this.props.innerRef.current.focusAtEnd();
|
||||
}
|
||||
};
|
||||
|
||||
getHeadings = () => {
|
||||
if (this.editor.current) {
|
||||
return this.editor.current.getHeadings();
|
||||
insertParagraph = () => {
|
||||
if (this.props.innerRef.current) {
|
||||
const { view } = this.props.innerRef.current;
|
||||
const { dispatch, state } = view;
|
||||
dispatch(state.tr.insert(0, state.schema.nodes.paragraph.create()));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => {
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
if (event.metaKey) {
|
||||
this.props.onSave({ publish: true, done: true });
|
||||
return;
|
||||
}
|
||||
|
||||
this.insertParagraph();
|
||||
this.focusAtStart();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
this.focusAtStart();
|
||||
return;
|
||||
}
|
||||
if (event.key === "s" && event.metaKey) {
|
||||
event.preventDefault();
|
||||
this.props.onSave({});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,49 +89,47 @@ class DocumentEditor extends React.Component<Props> {
|
||||
isDraft,
|
||||
isShare,
|
||||
readOnly,
|
||||
innerRef,
|
||||
} = this.props;
|
||||
const { emoji } = parseTitle(title);
|
||||
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<React.Suspense fallback={<LoadingPlaceholder />}>
|
||||
<Title
|
||||
type="text"
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
value={!title && readOnly ? document.titleWithDefault : title}
|
||||
style={
|
||||
startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined
|
||||
}
|
||||
readOnly={readOnly}
|
||||
autoFocus={!title}
|
||||
maxLength={100}
|
||||
<Title
|
||||
type="text"
|
||||
onChange={onChangeTitle}
|
||||
onKeyDown={this.handleTitleKeyDown}
|
||||
placeholder={document.placeholder}
|
||||
value={!title && readOnly ? document.titleWithDefault : title}
|
||||
style={startsWithEmojiAndSpace ? { marginLeft: "-1.2em" } : undefined}
|
||||
readOnly={readOnly}
|
||||
disabled={readOnly}
|
||||
autoFocus={!title}
|
||||
maxLength={100}
|
||||
/>
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
ref={innerRef}
|
||||
autoFocus={title && !this.props.defaultValue}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
grow
|
||||
{...this.props}
|
||||
/>
|
||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||
{this.activeLinkEvent && !isShare && readOnly && (
|
||||
<HoverPreview
|
||||
node={this.activeLinkEvent.target}
|
||||
event={this.activeLinkEvent}
|
||||
onClose={this.handleLinkInactive}
|
||||
/>
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentHistoryUrl(document)}
|
||||
/>
|
||||
<Editor
|
||||
ref={this.editor}
|
||||
autoFocus={title && !this.props.defaultValue}
|
||||
placeholder="…the rest is up to you"
|
||||
onHoverLink={this.handleLinkActive}
|
||||
scrollTo={window.location.hash}
|
||||
grow
|
||||
{...this.props}
|
||||
/>
|
||||
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
|
||||
{this.activeLinkEvent && !isShare && readOnly && (
|
||||
<HoverPreview
|
||||
node={this.activeLinkEvent.target}
|
||||
event={this.activeLinkEvent}
|
||||
onClose={this.handleLinkInactive}
|
||||
/>
|
||||
)}
|
||||
</React.Suspense>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -125,10 +140,10 @@ const Title = styled(Textarea)`
|
||||
line-height: 1.25;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.5em;
|
||||
text: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
color: ${(props) => props.theme.text};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.text};
|
||||
font-size: 2.25em;
|
||||
font-weight: 500;
|
||||
outline: none;
|
||||
@@ -138,6 +153,7 @@ const Title = styled(Textarea)`
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
-webkit-text-fill-color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
EditIcon,
|
||||
GlobeIcon,
|
||||
PlusIcon,
|
||||
MoreIcon,
|
||||
} from "outline-icons";
|
||||
import { transparentize, darken } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -336,6 +337,15 @@ class Header extends React.Component<Props> {
|
||||
<DocumentMenu
|
||||
document={document}
|
||||
isRevision={isRevision}
|
||||
label={
|
||||
<Button
|
||||
icon={<MoreIcon />}
|
||||
iconColor="currentColor"
|
||||
borderOnHover
|
||||
neutral
|
||||
small
|
||||
/>
|
||||
}
|
||||
showToggleEmbeds={canToggleEmbeds}
|
||||
showPrint
|
||||
/>
|
||||
|
||||
@@ -15,9 +15,10 @@ class MarkAsViewed extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
const { document } = this.props;
|
||||
|
||||
this.viewTimeout = setTimeout(() => {
|
||||
this.viewTimeout = setTimeout(async () => {
|
||||
if (document.publishedAt) {
|
||||
document.view();
|
||||
const view = await document.view();
|
||||
document.updateLastViewed(view);
|
||||
}
|
||||
}, MARK_AS_VIEWED_AFTER);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ const DocumentLink = styled(Link)`
|
||||
&:active,
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.listItemHoverBackground};
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class References extends React.Component<Props> {
|
||||
)}
|
||||
{showBacklinks && (
|
||||
<Tab to="#backlinks" isActive={() => isBacklinksTab}>
|
||||
References
|
||||
Referenced by
|
||||
</Tab>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
}
|
||||
|
||||
setupOnce = () => {
|
||||
if (this.context && !this.previousContext) {
|
||||
if (this.context && 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 } from "utils/routeHelpers";
|
||||
import { collectionUrl, documentUrl } from "utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
history: RouterHistory,
|
||||
@@ -24,15 +24,27 @@ 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 this.props.document.delete();
|
||||
if (this.props.ui.activeDocumentId === this.props.document.id) {
|
||||
this.props.history.push(
|
||||
collectionUrl(this.props.document.collectionId)
|
||||
);
|
||||
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));
|
||||
}
|
||||
this.props.onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
|
||||
|
||||
<Actions align="center" justify="flex-end">
|
||||
<Action>
|
||||
<InputSearch />
|
||||
<InputSearch source="drafts" />
|
||||
</Action>
|
||||
<Action>
|
||||
<NewDocumentMenu />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import CenteredContent from "components/CenteredContent";
|
||||
import Empty from "components/Empty";
|
||||
import PageTitle from "components/PageTitle";
|
||||
@@ -10,8 +11,8 @@ const Error404 = () => {
|
||||
<PageTitle title="Not Found" />
|
||||
<h1>Not found</h1>
|
||||
<Empty>
|
||||
We were unable to find the page you’re looking for. Go to the
|
||||
<a href="/">homepage</a>?
|
||||
We were unable to find the page you’re looking for. Go to the{" "}
|
||||
<Link to="/home">homepage</Link>?
|
||||
</Empty>
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
+30
-15
@@ -49,13 +49,14 @@ type Props = {
|
||||
@observer
|
||||
class Search extends React.Component<Props> {
|
||||
firstDocument: ?React.Component<typeof DocumentPreview>;
|
||||
lastQuery: string = "";
|
||||
|
||||
@observable
|
||||
query: string = decodeURIComponent(this.props.match.params.term || "");
|
||||
@observable params: URLSearchParams = new URLSearchParams();
|
||||
@observable offset: number = 0;
|
||||
@observable allowLoadMore: boolean = true;
|
||||
@observable isFetching: boolean = false;
|
||||
@observable isLoading: boolean = false;
|
||||
@observable pinToTop: boolean = !!this.props.match.params.term;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -81,14 +82,17 @@ class Search extends React.Component<Props> {
|
||||
}
|
||||
|
||||
handleKeyDown = (ev) => {
|
||||
// Escape
|
||||
if (ev.which === 27) {
|
||||
ev.preventDefault();
|
||||
this.goBack();
|
||||
if (ev.key === "Enter") {
|
||||
this.fetchResults();
|
||||
return;
|
||||
}
|
||||
|
||||
// Down
|
||||
if (ev.which === 40) {
|
||||
if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
return this.goBack();
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown") {
|
||||
ev.preventDefault();
|
||||
if (this.firstDocument) {
|
||||
const element = ReactDOM.findDOMNode(this.firstDocument);
|
||||
@@ -103,7 +107,7 @@ class Search extends React.Component<Props> {
|
||||
this.allowLoadMore = true;
|
||||
|
||||
// To prevent "no results" showing before debounce kicks in
|
||||
this.isFetching = true;
|
||||
this.isLoading = true;
|
||||
|
||||
this.fetchResultsDebounced();
|
||||
};
|
||||
@@ -115,7 +119,7 @@ class Search extends React.Component<Props> {
|
||||
this.allowLoadMore = true;
|
||||
|
||||
// To prevent "no results" showing before debounce kicks in
|
||||
this.isFetching = !!this.query;
|
||||
this.isLoading = !!this.query;
|
||||
|
||||
this.fetchResultsDebounced();
|
||||
};
|
||||
@@ -174,7 +178,7 @@ class Search extends React.Component<Props> {
|
||||
@action
|
||||
loadMoreResults = async () => {
|
||||
// Don't paginate if there aren't more results or we’re in the middle of fetching
|
||||
if (!this.allowLoadMore || this.isFetching) return;
|
||||
if (!this.allowLoadMore || this.isLoading) return;
|
||||
|
||||
// Fetch more results
|
||||
await this.fetchResults();
|
||||
@@ -183,7 +187,14 @@ class Search extends React.Component<Props> {
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (this.query) {
|
||||
this.isFetching = true;
|
||||
// we just requested this thing – no need to try again
|
||||
if (this.lastQuery === this.query) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.lastQuery = this.query;
|
||||
|
||||
try {
|
||||
const results = await this.props.documents.search(this.query, {
|
||||
@@ -203,15 +214,19 @@ class Search extends React.Component<Props> {
|
||||
} else {
|
||||
this.offset += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
} catch (err) {
|
||||
this.lastQuery = "";
|
||||
throw err;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
this.isLoading = false;
|
||||
}
|
||||
} else {
|
||||
this.pinToTop = false;
|
||||
this.lastQuery = this.query;
|
||||
}
|
||||
};
|
||||
|
||||
fetchResultsDebounced = debounce(this.fetchResults, 350, {
|
||||
fetchResultsDebounced = debounce(this.fetchResults, 500, {
|
||||
leading: false,
|
||||
trailing: true,
|
||||
});
|
||||
@@ -231,14 +246,14 @@ class Search extends React.Component<Props> {
|
||||
render() {
|
||||
const { documents, notFound, location } = this.props;
|
||||
const results = documents.searchResults(this.query);
|
||||
const showEmpty = !this.isFetching && this.query && results.length === 0;
|
||||
const showEmpty = !this.isLoading && this.query && results.length === 0;
|
||||
const showShortcutTip =
|
||||
!this.pinToTop && location.state && location.state.fromMenu;
|
||||
|
||||
return (
|
||||
<Container auto>
|
||||
<PageTitle title={this.title} />
|
||||
{this.isFetching && <LoadingIndicator />}
|
||||
{this.isLoading && <LoadingIndicator />}
|
||||
{notFound && (
|
||||
<div>
|
||||
<h1>Not Found</h1>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user