Compare commits

...

129 Commits

Author SHA1 Message Date
Tom Moor f3e3651222 0.48.0 2020-09-26 17:18:34 -07:00
Tom Moor b3d9478486 Merge pull request #1563 from outline/release-0.48.0
release: v48.0.0
2020-09-26 17:09:40 -07:00
Tom Moor f26aeca46a chore(migrations): Add missing indexes 2020-09-26 14:01:04 -07:00
Tom Moor f1a95e5e79 feat: Improved search results when finding links in document editor (#1573)
* feat: Improved search results when finding links in document editor

* chore(deps): Bump RME for smoother link search
2020-09-26 11:07:49 -07:00
Tom Moor 6f1f855083 feat: Add tracking of search source in UI 2020-09-24 21:56:37 -07:00
Tom Moor 40d52e9a78 fix: Cannot press down arrow to navigate via keyboard to search results
(due to withRouter converting DocumentPreview to a functional component)
2020-09-24 21:36:31 -07:00
Tom Moor a2f2971fec fix: Reduce liklihood of false search queries
fix: Reduce possibility of dupe search queries
feat: Allow 'Enter' to trigger search before debounce
2020-09-24 21:29:00 -07:00
Tom Moor d89808ce9d fix: Home link on 'Not Found' page 2020-09-24 19:38:19 -07:00
Tom Moor a43cc9c5a9 chore(deps): Upgrade RME for improved doc search results 2020-09-24 19:35:28 -07:00
Tom Moor bb7fcd1b67 feat: Allow embedding /share/ routes in iframes 2020-09-23 19:26:18 -07:00
Tom Moor c1957025ec fix: Dont dupe record search queries when paging results
feat: Record queries via api tokens separately
2020-09-21 23:31:10 -07:00
Renan Filipe 98626ebbaf feat: Record search queries (#1554)
* Record search queries

* feat: add totalCount to the search response

* feat: add comments to explain why we use setTimeout
2020-09-21 23:05:42 -07:00
Tom Moor 0fa8a6ed2e feat: Add ctx.state.authType for tracking (#1567) 2020-09-21 22:02:37 -07:00
Tom Moor fa96891c8e fix(editor): Upgrade RME, improved link pasting 2020-09-21 20:33:58 -07:00
Tom Moor 9aa81dcf82 fix: Error deleting account as only admin not displayed to user 2020-09-21 20:31:06 -07:00
Tom Moor c04d5bdfb0 flow 2020-09-21 19:47:17 -07:00
Tom Moor ea69d09562 fix: Guard usage of localStorage 2020-09-21 19:43:51 -07:00
Tom Moor c8ff5cf221 fix: Styling of 'New' badge in dark mode
fix: less than a min ago -> just now
2020-09-21 19:35:51 -07:00
Tom Moor 5638f7a687 Merge develop 2020-09-21 19:18:34 -07:00
Tom Moor 1293f52552 lint 2020-09-21 19:18:09 -07:00
Tom Moor 86812cfe76 fix(editor): Upgrade RME, fixes cursor navigation around headings in FF 2020-09-21 19:11:54 -07:00
Tom Moor d9b7384853 fix: Improve handling of invalid file type passed to documents.import API endpoint 2020-09-21 00:34:13 -07:00
Tom Moor 292afd774d fix: CMD+Enter in title should leave editing mode 2020-09-21 00:21:35 -07:00
Tom Moor d3d286b1be tweak search highlight 2020-09-21 00:07:32 -07:00
Tom Moor 26b9566b96 fix: Various fixes for unread tracking 2020-09-20 23:37:09 -07:00
Guilherme DIniz d487da8f15 feat: Visually differentiate unread documents (#1507)
* feat: Visually differentiate unread documents

* feat: add document treatment in document preview

* fix requested changes

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-09-20 22:32:28 -07:00
Tom Moor 4ffc04bc5d fix: Allow selection of embeds (#1562)
* feat: Support importing .docx or .html files as new documents (#1551)

* Support importing .docx as new documents

* Add html file support, build types and interface for easily adding file types to importer

* fix: Upload embedded images in docx to storage

* refactor: Bulk of logic to command

* refactor: Do all importing on server, so we're not splitting logic for import into two places

* test: Add documentImporter tests


Co-authored-by: Lance Whatley <whatl3y@gmail.com>

* fix: Accessibility audit

* fix: Quick fix, non editable title
closes #1560

* fix: Embed selection

Co-authored-by: Lance Whatley <whatl3y@gmail.com>
2020-09-20 22:27:11 -07:00
Tom Moor 68148bd4d8 fix: Quick fix, non editable title
closes #1560
2020-09-18 08:36:57 -07:00
Tom Moor 881105992e fix: Accessibility audit 2020-09-16 22:31:14 -07:00
Tom Moor 2c1a111dee feat: Support importing .docx or .html files as new documents (#1551)
* Support importing .docx as new documents

* Add html file support, build types and interface for easily adding file types to importer

* fix: Upload embedded images in docx to storage

* refactor: Bulk of logic to command

* refactor: Do all importing on server, so we're not splitting logic for import into two places

* test: Add documentImporter tests


Co-authored-by: Lance Whatley <whatl3y@gmail.com>
2020-09-16 21:54:33 -07:00
Tom Moor e67d319e2b fix: Update DocumentMetaWithViews to hooks, correctly observe store changes
closes #1555
2020-09-16 21:15:21 -07:00
Tom Moor 85f7e03921 test: No hotreload in test env 2020-09-16 08:22:50 -07:00
Tom Moor e30adbaac2 fix: Flip production/development NODE_ENV logic
closes #1548
2020-09-16 00:13:12 -07:00
Tom Moor ac8f0ebaac feat: Allow Google Embeds from regular (non publish-to-web) links (#1533)
Improve styling to allow getting back to source document
2020-09-15 18:38:42 -07:00
Tom Moor b3b71d2dc7 feat: Editable titles in sidebar (#1544)
* feat/editable-titles

* feat: Double click to edit titles in the sidebar

* Take into account policies

* fix: Title update on another client

* Improved styling
2020-09-15 18:01:40 -07:00
Tom Moor ab3613af48 fix: Path to onboarding markdown files (changed with updated build process) 2020-09-15 12:20:27 -07:00
Tom Moor 3940f1a108 Update README.md 2020-09-14 19:29:55 -07:00
Tom Moor 142e7da6a5 fix: Placeholder style 2020-09-13 20:26:38 -07:00
Tom Moor 021de66f7a fix: Document titles look faded in Safari 2020-09-13 20:18:50 -07:00
Nan Yu 55858d5d7d fix: put guard around sentry init (#1547) 2020-09-13 19:54:08 -07:00
Tom Moor 93d3582ac7 fix: Dead pointer zone over links when hover card is showing 2020-09-13 19:49:25 -07:00
Tom Moor 0b2107c1ee Merge develop 2020-09-13 10:50:13 -07:00
Tom Moor f8a167fd4b Merge branch 'develop' of github.com:outline/outline into develop 2020-09-13 10:47:31 -07:00
Tom Moor 608be3deef refactor: Remove babel/register for instant production server startup (#1481)
* refactor: Remove babel/register for instant production server startup

* fix: Update procfile location

* fix: package.json must be copied, not linked for production build

* fix: Production file paths

* fix: Public assets path

* Remove unused scripts
2020-09-13 10:46:33 -07:00
Tom Moor 56551d1ab3 fix: Error in backlinks service when updating an old document (pre v1) 2020-09-13 01:03:39 -07:00
Tom Moor 450d6b7e42 fix: Focus accessibility (#1536)
* fix: Focus ring appearing on click
fix: Focus ring not appearing on sidebar links
add: focus-visible polyfill

* fix: More visible outlines on keyboard focus
fix: Header block should be a button semantically
2020-09-12 23:27:23 -07:00
Matheus Rocha Vieira fc98cf78e6 chore: Upgrade Sentry to Latest Version (#1541)
* Upgrade Sentry to Latest Version
2020-09-12 23:24:32 -07:00
Nan Yu d9aa53a094 feat: allow searching for urls of internal documents (#1529)
* core search logic

* bump version of rich markdown editor

* let shift and meta modifiers do their thing when clicking on a link in a doc

* version bump editor

* test: Add parseDocumentSlug test

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-09-12 23:23:40 -07:00
Nan Yu ffab4fbf76 remove -1 bottom from navigation links 2020-09-12 11:24:58 -07:00
Tom Moor 2161fba1dd Update LICENSE 2020-09-11 12:28:10 -07:00
Tom Moor cd2cdd025c 0.47.1 2020-09-11 12:26:13 -07:00
Tom Moor d5f5319f80 fix: History sidebar header shrinking when lots of history 2020-09-11 12:10:40 -07:00
Tom Moor c298c73240 fix: Revision skipped after identical previous autosave 2020-09-11 12:06:57 -07:00
Tom Moor 38d1831259 fix: Send events for draft documents down user channel always 2020-09-11 11:19:30 -07:00
Tom Moor 9c9b95741c fix: Unable to edit collection description
closes #1523
2020-09-09 23:23:01 -07:00
Tom Moor cc8db7e991 fix: Save team logo automatically
closes #1521
2020-09-08 21:12:37 -07:00
Tom Moor c5b7d9be13 fix: Flash of sidebar when navigating between documents 2020-09-08 20:48:52 -07:00
Tom Moor be2e46b5d2 References -> Referenced by
Less ambiguous as to the backlink direction
2020-09-08 18:44:19 -07:00
Tom Moor 4b2a766357 fix: Missing toast message when successfully copying code 2020-09-08 07:31:03 -07:00
Tom Moor f264d67862 fix: Two API endpoints where requesting a permenantly deleted document results in server error 2020-09-08 07:29:06 -07:00
Tom Moor b1648ac2aa Update LICENSE 2020-09-07 20:25:16 -07:00
Tom Moor 25423d8c85 0.47.0 2020-09-07 20:24:40 -07:00
Nan Yu e7ab2939d4 fix: Improved handling of delete events from collection and document sockets (#1517)
* handle delete events fron collection and document sockets

* handle collection deletes to documents

* rework semantics to always reload after a delete

Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-09-07 19:05:10 -07:00
Tom Moor ceeac9b982 fix: Deeply nested document titles not updated in collection documentStructure cache
closes #1508
2020-09-07 13:41:17 -07:00
Tom Moor 5de2f969e3 fix: Preload membership 2020-09-07 12:17:04 -07:00
Tom Moor b54901d50c fix: CMD+Eneter in title should still publish 2020-09-07 12:06:03 -07:00
Tom Moor 4de3f69474 fix: Documents in deleted collection should appear in trash (#1362)
* fix: Documents from deleted collection should show in trash

* improve messaging

* test: Add documents.move tests
feat: Add ability to restore trashed documents from deleted collection

* update store

* fix

* ui

* lint

* fix: Improve breadcrumb
2020-09-07 11:51:09 -07:00
Tom Moor c5de2da115 Merge branch 'develop' of github.com:outline/outline into develop 2020-09-07 10:43:36 -07:00
Tom Moor 709c3e78bd fix: Occasional render loop in editor toolbar (#1518)
* fix: CMD+S should save when editor title is focused

* fix: Bump RME, fixes various small editor issues
2020-09-07 10:42:51 -07:00
Tom Moor acb04fdf1a fix: CMD+S should save when editor title is focused 2020-09-06 10:33:39 -07:00
Tom Moor f13696dd2a Update yarn.lock 2020-09-05 19:58:17 -07:00
Tom Moor d6f245d67e Bump RME: Fixes inline styles in table cells with newlines 2020-09-05 19:51:12 -07:00
Tom Moor f2abf38fe4 perf: Remove source once compiled 2020-09-05 12:57:27 -07:00
Tom Moor f0712e22d8 perf: Improving dockerfile 2020-09-05 12:44:40 -07:00
Tom Moor e7e289d9fa end 2020-09-04 23:28:29 -07:00
Tom Moor 713187cfb4 fix 2020-09-04 15:39:36 -07:00
Tom Moor 11d3a5c9b9 fix: Enter in document title should create a newline at the top of editor and focus
closes #1511
2020-09-04 13:23:33 -07:00
Matheus Rocha Vieira cf1e506009 feat: Add ClickUp Embed Service (#1465)
* Add Clickup Embed Service

* Transparency Icon


Co-authored-by: Tom Moor <tom.moor@gmail.com>
2020-09-04 13:21:27 -07:00
Tom Moor 6b6d67beb6 fix: Allow disabling SSL for Postgres connection with standard PGSSLMODE env
closes #1501
2020-09-04 12:51:48 -07:00
Tom Moor a98e8ad8df fix: Breadcrumb overflow color is inaccessible
closes #1505
2020-09-04 12:49:20 -07:00
Tom Moor 9049785d98 fix: e shortcut to edit doesn't work when title is selected
closes #1510
2020-09-04 12:42:41 -07:00
Tom Moor e8648d4611 fix: Error in shares.info endpoint when requesting share record for deleted document 2020-09-03 23:22:41 -07:00
Tom Moor dd7436f78c fix: async error in backlinks service when dealing with a deleted document 2020-09-03 22:44:42 -07:00
Tom Moor b93a397ab3 feat: Bump RME 2020-09-03 22:27:30 -07:00
Tom Moor 206160582e chore: Tweak ordering of unpublish menu item 2020-09-02 20:19:36 -07:00
Tom Moor 4bf5926ee3 Bump RME 2020-08-31 21:57:26 -07:00
Tom Moor 82433e02a0 feat: Add custom error state for chunk loading failed 2020-08-31 21:09:23 -07:00
Tom Moor 637a9b5cf9 refactor: Remove unused event handlers 2020-08-31 20:44:19 -07:00
Tom Moor 95b91c466a fix: Disallow creating nested document within document in trash 2020-08-31 20:32:08 -07:00
Tom Moor 4edf90a184 fix: Development warning, missing key on event list items 2020-08-31 20:29:55 -07:00
Tom Moor 31522b0d6f fix: Local shares state not cleared when document is deleted 2020-08-31 20:28:41 -07:00
Tom Moor 759d4a5ac2 fix: Handle error revoking share link on frontend 2020-08-31 20:24:05 -07:00
Tom Moor dd0d51dd9d test 2020-08-31 20:23:51 -07:00
Tom Moor 8550116c6b fix: shares.list should not return shares for deleted documents
fix: shares.info should not return info for revoked shares
closes #1492
2020-08-31 20:15:10 -07:00
Tom Moor 3c7dc93982 Merge branch 'develop' of github.com:outline/outline into develop 2020-08-31 20:06:44 -07:00
Guilherme DIniz 0aa338cccc feat: Allow unpublishing documents (#1467)
* Allow unpublishing documents

* add block unpublish files that has children

* add api tests to new route
2020-08-31 20:03:05 -07:00
Tom Moor 8f41895e66 Merge develop 2020-08-31 19:40:41 -07:00
Tom Moor de8ac4acf5 fix: Configure mobx-react-lite observer batching
Removes development warning
2020-08-31 18:42:12 -07:00
Tom Moor de59147418 chore: Upgrade Sentry to 5.22.3
closes #1498
2020-08-31 18:36:30 -07:00
Tom Moor cf522cc85f fix: Regression with TOC not showing when navigating directly to document (#1500)
fix: Editing document too wide when TOC visible in read only
2020-08-31 18:31:13 -07:00
Tom Moor 8c7200fa87 chore: yarn deduplicate 2020-08-30 19:44:30 -07:00
Tom Moor f2310be173 Updated Yarn lockfile 2020-08-29 12:11:12 -07:00
Tom Moor 29f4dc9331 Bump RME
Fixes #1107 - It's now possible to use line breaks in table cells with Shift+Enter
Fixes #1253 - Selected content can now be dragged to reorder
2020-08-29 12:00:55 -07:00
Tom Moor 03b6dd62a8 fix: Missing click action to change permissions on a collection
fix: Modals no longer stacking correctly since upgrading react-portal
2020-08-25 21:00:50 -07:00
Tom Moor 7f0c608dbb Merge branch 'guilherme-diniz-feature/document-history-header' into develop 2020-08-25 20:04:02 -07:00
Tom Moor c52fbb944e Styling tidy up 2020-08-25 20:03:52 -07:00
Tom Moor e22e952606 Merge branch 'feature/document-history-header' of git://github.com/guilherme-diniz/outline into guilherme-diniz-feature/document-history-header 2020-08-25 19:44:56 -07:00
Guilherme Diniz 197cdff6c3 fix layout issues 2020-08-25 17:22:13 -03:00
Tom Moor 85d09b2351 fix: Deleting a document should correctly show who deleted (#1488) 2020-08-25 08:51:12 -07:00
Tom Moor 69611638b9 fix: Redirect to parent document when deleting a child document if possible (#1489) 2020-08-25 08:45:04 -07:00
Tom Moor e117d5f103 fix: Unable to view all possible locations when moving document (#1490)
* fix: Remove limit of displayed results on Move dialog

* fix: Filter templates from results

* Show final document location on hover/active, reduces visual noise
2020-08-25 08:44:46 -07:00
Tom Moor 03db975217 Merge branch 'feature/document-history-header' of git://github.com/guilherme-diniz/outline into guilherme-diniz-feature/document-history-header 2020-08-24 23:46:16 -07:00
Tom Moor 76279902f9 chore: Introduce AWS_S3_FORCE_PATH_STYLE option to maintain compatability with Minio et al (#1443)
- Make AWS_S3_UPLOAD_BUCKET_NAME optional
2020-08-24 23:27:10 -07:00
Tom Moor a304e91ffc Merge branch 'develop' into perf/issue-1464 2020-08-24 20:58:56 -07:00
Tom Moor 9b5573c5e2 0.46.1 2020-08-24 20:22:08 -07:00
Tom Moor ec61efa12b Remove unused scripts 2020-08-23 21:10:32 -07:00
Tom Moor b01778a39f fix: Public assets path 2020-08-23 20:44:44 -07:00
Tom Moor 5aa092853b fix: Production file paths 2020-08-23 20:35:59 -07:00
Tom Moor 1fa3db4bdc fix: package.json must be copied, not linked for production build 2020-08-23 20:29:17 -07:00
Tom Moor 6a9f74e6cc fix: Update procfile location 2020-08-23 19:21:43 -07:00
Tom Moor e8719340d1 refactor: Remove babel/register for instant production server startup 2020-08-23 19:10:16 -07:00
Tom Moor 70838918c3 fix: Collections not collapsing 2020-08-23 12:51:35 -07:00
Tom Moor ec38f5d79c refactor: Remove old react lifecycle methods (#1480)
* refactor: Remove deprecated APIs

* bump mobx-react for hooks support

* inject -> useStores
https://mobx-react.js.org/recipes-migration\#hooks-to-the-rescue

* chore: React rules of hooks lint
2020-08-23 11:51:56 -07:00
Jonathan Killian 179176c312 fix: Update package.json build script to use yarn instead of npm. (#1476)
* fix: Update package.json build script to yarn.

Update package.json build script to use yarn instead of npm to maintain consistency with the rest of scripts. I was running into an issue with the Dockerfile when using nvm with yarn and this fixed the issue.
2020-08-22 19:56:52 -07:00
Tom Moor c446a91f7d fix: Restore Postgres SSL support on Heroku
https://github.com/brianc/node-postgres/issues/2009
2020-08-22 08:27:42 -07:00
Guilherme Diniz 05f48f054b feat: Add Header to Document History Sidebar 2020-08-21 20:58:57 -03:00
Tom Moor ec55299c8b fix: Improve websocket reliability (#1470)
* check connection on page visibility change

* fix: SocketPresence account for socket changing
2020-08-20 20:37:54 -07:00
Tom Moor 26c574ab58 chore: Upgrade pg and sequelize to support node 14+ (#1462)
* Upgrade pg and sequelize to support node 14+

When Node 14 came out the app was incompatible, we should always have a maximum version defined here until the server code has been tested to prove compatibility

Co-authored-by: Lance Whatley <whatl3y@gmail.com>
2020-08-20 20:19:44 -07:00
Tom Moor 6dd6768f07 feat: Allow moving templates between collections (#1454)
- Allow template move in document policy
- fix: Ensure that document is not added to collection structure in documentMover command
- fix: Moving a template should now show nested documents as options
- fix: Hitting 'm' should not allow moving a draft
- fix: Styling of seperators on move screen
2020-08-20 19:46:29 -07:00
179 changed files with 4156 additions and 1827 deletions
+1 -1
View File
@@ -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:
+19
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -4,7 +4,8 @@
"react-app",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:flowtype/recommended"
"plugin:flowtype/recommended",
"plugin:react-hooks/recommended"
],
"plugins": [
"prettier",
+2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
web: node index.js
web: node ./build/server/index.js
+28 -30
View File
@@ -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
+5
View File
@@ -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 -3
View File
@@ -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;
+75 -32
View File
@@ -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" />
&nbsp;
<span>Trash</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isArchived) {
return (
<>
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>Archive</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isDraft) {
return (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
);
}
if (document.isTemplate) {
return (
<>
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<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" />
&nbsp;
<span>Templates</span>
</CollectionName>
<Slash />
</>
)}
{isDraft && (
<>
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
</CollectionName>
<Slash />
</>
)}
<Icon document={document} />
<CollectionName to={collectionUrl(collection.id)}>
<CollectionIcon collection={collection} expanded />
&nbsp;
@@ -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 -22
View File
@@ -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;
}
`};
`;
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
+30 -8
View File
@@ -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 (
<>
&nbsp;<Modified highlight>Never viewed</Modified>
</>
);
}
return (
<span>
&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};
return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
@@ -115,6 +136,7 @@ function DocumentMeta({
</strong>
</span>
)}
&nbsp;{timeSinceNow()}
{children}
</Container>
);
+6 -6
View File
@@ -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;
+4 -5
View File
@@ -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;
}
`};
`;
+2 -2
View File
@@ -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;
+20 -1
View File
@@ -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 ? (
+1 -1
View File
@@ -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;
`;
+13 -8
View File
@@ -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 {
+2 -2
View File
@@ -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,
+5 -1
View File
@@ -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,
})
);
};
+6 -5
View File
@@ -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+/")
+2 -1
View File
@@ -17,7 +17,8 @@ class Mask extends React.Component<Props> {
return false;
}
componentWillMount() {
constructor() {
super();
this.width = randomInteger(75, 100);
}
+24 -16
View File
@@ -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;
-8
View File
@@ -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) => (
+14 -4
View File
@@ -42,20 +42,23 @@ class PathToDocument extends React.Component<Props> {
return (
<Component ref={ref} onClick={this.handleClick} href="" selectable>
{collection && <CollectionIcon collection={collection} />}
&nbsp;
{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;
}
}
`;
+35 -40
View File
@@ -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)));
+48 -11
View File
@@ -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 (
-9
View File
@@ -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) {
+3 -2
View File
@@ -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
View File
@@ -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>
);
+1
View File
@@ -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})`}
/>
+1
View File
@@ -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
+28
View File
@@ -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"
/>
);
}
}
+17
View File
@@ -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);
});
});
+1 -1
View File
@@ -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" />;
}
}
+1
View File
@@ -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
+8 -1
View File
@@ -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
/>
);
}
}
+2
View File
@@ -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"
+16 -4
View File
@@ -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
/>
);
}
}
+10 -5
View File
@@ -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);
+16 -4
View File
@@ -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
/>
);
}
}
+4 -4
View File
@@ -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);
+15 -5
View File
@@ -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
/>
);
+4 -4
View File
@@ -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);
+9 -1
View File
@@ -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
View File
@@ -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" />;
}
}
+1
View File
@@ -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"
/>
+8 -1
View File
@@ -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
/>
);
}
}
+1
View File
@@ -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
+1
View File
@@ -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})`}
/>
+5 -1
View File
@@ -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
View File
@@ -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 />
);
}
}
+1
View File
@@ -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}`}
+1
View File
@@ -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}`}
+7 -1
View File
@@ -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"
/>
);
}
}
+1
View File
@@ -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})`}
/>
+2
View File
@@ -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})`}
/>
+64 -6
View File
@@ -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) => (
+8
View File
@@ -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",
+10
View File
@@ -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;
}
+8
View File
@@ -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
View File
@@ -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
);
}
+17 -8
View File
@@ -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}>
+64 -8
View File
@@ -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} />
&nbsp;{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>
)}
+1 -1
View File
@@ -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);
};
+8 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
+3 -2
View File
@@ -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…" : "Im sure  Delete"}
+5 -13
View File
@@ -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>
+1 -1
View File
@@ -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 />
+54 -8
View File
@@ -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,
+56 -55
View File
@@ -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>
+91 -44
View File
@@ -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;
`;
+65 -49
View File
@@ -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};
}
`;
+10
View File
@@ -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;
}
`;
+1 -1
View File
@@ -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) {
+18 -6
View File
@@ -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) {
+1 -1
View File
@@ -37,7 +37,7 @@ class Drafts extends React.Component<Props> {
<Actions align="center" justify="flex-end">
<Action>
<InputSearch />
<InputSearch source="drafts" />
</Action>
<Action>
<NewDocumentMenu />
+3 -2
View File
@@ -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 youre looking for. Go to the&nbsp;
<a href="/">homepage</a>?
We were unable to find the page youre looking for. Go to the{" "}
<Link to="/home">homepage</Link>?
</Empty>
</CenteredContent>
);
+30 -15
View File
@@ -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 were 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